openzeppelin_relayer/utils/
redis.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use color_eyre::Result;
5use deadpool_redis::{Config, Metrics, Pool, Runtime};
6use tracing::{debug, info, warn};
7
8use crate::config::ServerConfig;
9
10// ============================================================================
11// Shared Timing Constants for Bootstrap Operations
12// ============================================================================
13
14/// Default lock TTL for bootstrap operations (2 minutes).
15/// Used as a safety net for crashes during initialization or config processing.
16pub const BOOTSTRAP_LOCK_TTL_SECS: u64 = 120;
17
18/// Max wait time when another instance holds the lock.
19/// Set slightly longer than lock TTL to handle edge cases.
20pub const LOCK_WAIT_MAX_SECS: u64 = 130;
21
22/// Polling interval when waiting for completion.
23pub const LOCK_POLL_INTERVAL_MS: u64 = 500;
24
25/// Holds separate connection pools for read and write operations.
26///
27/// This struct enables optimization for Redis deployments with read replicas,
28/// such as AWS ElastiCache. Write operations use the primary pool, while read
29/// operations can be distributed across reader replicas.
30///
31/// When `REDIS_READER_URL` is not configured, both pools point to the same
32/// Redis instance (the primary), maintaining backward compatibility.
33#[derive(Clone, Debug)]
34pub struct RedisConnections {
35    /// Pool for write operations (connected to primary endpoint)
36    primary_pool: Arc<Pool>,
37    /// Pool for read operations (connected to reader endpoint, or primary if not configured)
38    reader_pool: Arc<Pool>,
39}
40
41impl RedisConnections {
42    /// Creates a new `RedisConnections` with a single pool used for both reads and writes.
43    ///
44    /// This is useful for:
45    /// - Testing where read/write separation is not needed
46    /// - Simple deployments without read replicas
47    /// - Backward compatibility
48    pub fn new_single_pool(pool: Arc<Pool>) -> Self {
49        Self {
50            primary_pool: pool.clone(),
51            reader_pool: pool,
52        }
53    }
54
55    /// Returns the primary pool for write operations.
56    ///
57    /// Use this for: `create`, `update`, `delete`, and any operation that
58    /// modifies data in Redis.
59    pub fn primary(&self) -> &Arc<Pool> {
60        &self.primary_pool
61    }
62
63    /// Returns the reader pool for read operations.
64    ///
65    /// Use this for: `get_by_id`, `list`, `find_by_*`, `count`, and any
66    /// operation that only reads data from Redis.
67    ///
68    /// If no reader endpoint is configured, this returns the same pool as `primary()`.
69    pub fn reader(&self) -> &Arc<Pool> {
70        &self.reader_pool
71    }
72}
73
74/// Creates a Redis connection pool with the specified URL, pool size, and configuration.
75async fn create_pool(url: &str, pool_max_size: usize, config: &ServerConfig) -> Result<Arc<Pool>> {
76    let cfg = Config::from_url(url);
77
78    let pool = cfg
79        .builder()
80        .map_err(|e| eyre::eyre!("Failed to create Redis pool builder for {}: {}", url, e))?
81        .max_size(pool_max_size)
82        .wait_timeout(Some(Duration::from_millis(config.redis_pool_timeout_ms)))
83        .create_timeout(Some(Duration::from_millis(
84            config.redis_connection_timeout_ms,
85        )))
86        .recycle_timeout(Some(Duration::from_millis(
87            config.redis_connection_timeout_ms,
88        )))
89        .runtime(Runtime::Tokio1)
90        .build()
91        .map_err(|e| eyre::eyre!("Failed to build Redis pool for {}: {}", url, e))?;
92
93    // Verify the pool is working by getting a connection
94    let conn = pool
95        .get()
96        .await
97        .map_err(|e| eyre::eyre!("Failed to get initial Redis connection from {}: {}", url, e))?;
98    drop(conn);
99
100    Ok(Arc::new(pool))
101}
102
103/// Initializes Redis connection pools for both primary and reader endpoints.
104///
105/// # Arguments
106///
107/// * `config` - The server configuration containing Redis URLs and pool settings.
108///
109/// # Returns
110///
111/// A `RedisConnections` struct containing:
112/// - `primary_pool`: Connected to `REDIS_URL` (for write operations)
113/// - `reader_pool`: Connected to `REDIS_READER_URL` if set, otherwise same as primary
114///
115/// # Features
116///
117/// - **Read/Write Separation**: When `REDIS_READER_URL` is configured, read operations
118///   can be distributed across read replicas, reducing load on the primary.
119/// - **Backward Compatible**: If `REDIS_READER_URL` is not set, both pools use
120///   the primary URL, maintaining existing behavior.
121/// - **Connection Pooling**: Both pools use deadpool-redis with configurable
122///   max size, wait timeout, and connection timeouts.
123///
124/// # Example Configuration
125///
126/// ```bash
127/// # AWS ElastiCache with read replicas
128/// REDIS_URL=redis://my-cluster.xxx.cache.amazonaws.com:6379
129/// REDIS_READER_URL=redis://my-cluster-ro.xxx.cache.amazonaws.com:6379
130/// ```
131pub async fn initialize_redis_connections(config: &ServerConfig) -> Result<Arc<RedisConnections>> {
132    let primary_pool_size = config.redis_pool_max_size;
133    let primary_pool = create_pool(&config.redis_url, primary_pool_size, config).await?;
134
135    info!(
136        primary_url = %config.redis_url,
137        primary_pool_size = %primary_pool_size,
138        "initializing primary Redis connection pool"
139    );
140    let reader_pool = match &config.redis_reader_url {
141        Some(reader_url) if !reader_url.is_empty() => {
142            let reader_pool_size = config.redis_reader_pool_max_size;
143
144            info!(
145                primary_url = %config.redis_url,
146                reader_url = %reader_url,
147                primary_pool_size = %primary_pool_size,
148                reader_pool_size = %reader_pool_size,
149                "Using separate reader endpoint for read operations"
150            );
151            create_pool(reader_url, reader_pool_size, config).await?
152        }
153        _ => {
154            debug!(
155                primary_url = %config.redis_url,
156                pool_size = %primary_pool_size,
157                "No reader URL configured, using primary for all operations"
158            );
159            primary_pool.clone()
160        }
161    };
162
163    // Bound pool connection lifetimes so fresh connections periodically
164    // re-resolve the endpoint DNS and follow it to its current node. A
165    // separately configured reader endpoint can repoint the same way (node
166    // replacement, scaling, blue/green), so a distinct reader pool gets its
167    // own recycler; without one, `reader_pool` aliases `primary_pool` and a
168    // single recycler covers both.
169    spawn_pool_age_recycler(&primary_pool, config.redis_connection_max_age_ms, "primary");
170    if !Arc::ptr_eq(&reader_pool, &primary_pool) {
171        spawn_pool_age_recycler(&reader_pool, config.redis_connection_max_age_ms, "reader");
172    }
173
174    Ok(Arc::new(RedisConnections {
175        primary_pool,
176        reader_pool,
177    }))
178}
179
180/// Age-eviction predicate for `Pool::retain`: keeps a connection while its age
181/// is under `max_age`, dropping it once it reaches the bound.
182fn should_retain_connection(metrics: &Metrics, max_age: Duration) -> bool {
183    metrics.created.elapsed() < max_age
184}
185
186/// Spawns a background task that evicts pool connections older than
187/// `max_age_ms` via `Pool::retain`; deadpool then lazily opens fresh
188/// connections on demand, which re-resolve DNS. `max_age_ms == 0` disables
189/// recycling and spawns nothing.
190///
191/// The sweep runs at half the max age, clamped to `[1s, 30s]`, so a
192/// connection can outlive the bound by up to one sweep interval — and for
193/// `max_age_ms` under ~2000 the 1s floor means the effective lifetime bound
194/// is roughly one second, not the configured value.
195///
196/// The task holds only a `Weak` reference to the pool and exits once the
197/// owning `RedisConnections` (and any checked-out connections) are dropped.
198///
199/// (`Pool::retain` is used rather than a custom `Manager` because deadpool-redis
200/// 0.22 has no hook to swap the manager without changing the concrete `Pool`
201/// type that `RedisConnections` holds.)
202fn spawn_pool_age_recycler(pool: &Arc<Pool>, max_age_ms: u64, pool_label: &'static str) {
203    if max_age_ms == 0 {
204        return;
205    }
206
207    let max_age = Duration::from_millis(max_age_ms);
208    let pool = Arc::downgrade(pool);
209
210    // Sweep at roughly half the max age (bounded to a sane range) so an aged
211    // connection is dropped well within one max-age window without hammering.
212    let interval = max_age
213        .div_f64(2.0)
214        .clamp(Duration::from_secs(1), Duration::from_secs(30));
215
216    tokio::spawn(async move {
217        loop {
218            tokio::time::sleep(interval).await;
219            let Some(pool) = pool.upgrade() else {
220                break;
221            };
222            let result = pool.retain(|_, metrics| should_retain_connection(&metrics, max_age));
223            if !result.removed.is_empty() {
224                debug!(
225                    pool = pool_label,
226                    removed = result.removed.len(),
227                    retained = result.retained,
228                    max_age_ms = max_age_ms,
229                    "recycled aged Redis connections"
230                );
231            }
232        }
233    });
234}
235
236/// A distributed lock implementation using Redis SET NX EX pattern.
237///
238/// This lock is designed for distributed systems where multiple instances
239/// need to coordinate exclusive access to shared resources. It uses the
240/// Redis SET command with NX (only set if not exists) and EX (expiry time)
241/// options to atomically acquire locks.
242///
243/// # Features
244/// - **Atomic acquisition**: Uses Redis SET NX to ensure only one instance can acquire the lock
245/// - **Automatic expiry**: Lock automatically expires after TTL to prevent deadlocks
246/// - **Unique identifiers**: Each lock acquisition gets a unique ID to prevent accidental release by other instances
247/// - **Auto-release on drop**: Lock is released when the guard is dropped (best-effort via spawned task)
248///
249/// # TTL Considerations
250/// The TTL should be set to accommodate the worst-case runtime of the protected operation.
251/// If the operation runs longer than TTL, another instance may acquire the lock concurrently.
252/// For long-running operations, consider:
253/// - Setting a generous TTL (e.g., 2x expected runtime)
254/// - Implementing lock refresh/extension during processing
255/// - Breaking the operation into smaller locked sections
256///
257/// # Example
258/// ```ignore
259/// // Key format: {prefix}:lock:{name}
260/// let lock_key = format!("{}:lock:{}", prefix, "my-operation");
261/// let lock = DistributedLock::new(redis_client, &lock_key, Duration::from_secs(60));
262/// if let Some(guard) = lock.try_acquire().await? {
263///     // Do exclusive work here
264///     // Lock is automatically released when guard is dropped
265/// }
266/// ```
267#[derive(Clone)]
268pub struct DistributedLock {
269    pool: Arc<Pool>,
270    lock_key: String,
271    ttl: Duration,
272}
273
274impl DistributedLock {
275    /// Creates a new distributed lock instance.
276    ///
277    /// # Arguments
278    /// * `pool` - Redis connection pool
279    /// * `lock_key` - Full Redis key for this lock (e.g., "myprefix:lock:cleanup")
280    /// * `ttl` - Time-to-live for the lock. Lock will automatically expire after this duration
281    ///   to prevent deadlocks if the holder crashes.
282    pub fn new(pool: Arc<Pool>, lock_key: &str, ttl: Duration) -> Self {
283        Self {
284            pool,
285            lock_key: lock_key.to_string(),
286            ttl,
287        }
288    }
289
290    /// Attempts to acquire the distributed lock.
291    ///
292    /// This is a non-blocking operation. If the lock is already held by another
293    /// instance, it returns `Ok(None)` immediately without waiting.
294    ///
295    /// # Returns
296    /// * `Ok(Some(LockGuard))` - Lock was successfully acquired
297    /// * `Ok(None)` - Lock is already held by another instance
298    /// * `Err(_)` - Redis communication error
299    ///
300    /// # Lock Semantics
301    /// The lock is implemented using Redis `SET key value NX EX ttl`:
302    /// - `NX`: Only set if key does not exist (atomic check-and-set)
303    /// - `EX`: Set expiry time in seconds
304    pub async fn try_acquire(&self) -> Result<Option<LockGuard>> {
305        let lock_value = generate_lock_value();
306        let mut conn = self.pool.get().await?;
307
308        // Use SET NX EX for atomic lock acquisition with expiry
309        let result: Option<String> = redis::cmd("SET")
310            .arg(&self.lock_key)
311            .arg(&lock_value)
312            .arg("NX")
313            .arg("EX")
314            .arg(self.ttl.as_secs())
315            .query_async(&mut conn)
316            .await?;
317
318        match result {
319            Some(_) => {
320                debug!(
321                    lock_key = %self.lock_key,
322                    ttl_secs = %self.ttl.as_secs(),
323                    "distributed lock acquired"
324                );
325                Ok(Some(LockGuard {
326                    release_info: Some(LockReleaseInfo {
327                        pool: self.pool.clone(),
328                        lock_key: self.lock_key.clone(),
329                        lock_value,
330                    }),
331                }))
332            }
333            None => {
334                debug!(
335                    lock_key = %self.lock_key,
336                    "distributed lock already held by another instance"
337                );
338                Ok(None)
339            }
340        }
341    }
342}
343
344/// A guard that represents an acquired distributed lock.
345///
346/// The lock is automatically released when this guard is dropped. This ensures
347/// the lock is released regardless of how the protected code exits (normal return,
348/// early return via `?`, or panic).
349///
350/// The release is performed via a spawned task to avoid blocking in `Drop`.
351/// If you need to confirm the release succeeded, call `release()` explicitly instead.
352///
353/// # Drop Behavior
354/// When dropped, the guard spawns an async task to release the lock. This is
355/// best-effort: if the task fails (e.g., Redis unavailable), the lock will
356/// still expire after TTL.
357pub struct LockGuard {
358    /// Release info is wrapped in Option to support both explicit release and Drop.
359    /// When `release()` is called, this is taken (set to None) to prevent double-release.
360    /// When Drop runs, if this is Some, we spawn a release task.
361    release_info: Option<LockReleaseInfo>,
362}
363
364/// Internal struct holding the information needed to release a lock.
365struct LockReleaseInfo {
366    pool: Arc<Pool>,
367    lock_key: String,
368    lock_value: String,
369}
370
371impl LockGuard {
372    /// Explicitly releases the lock before TTL expiry.
373    ///
374    /// This uses a Lua script to ensure we only delete the lock if we still own it.
375    /// This prevents accidentally releasing a lock that was already expired and
376    /// re-acquired by another instance.
377    ///
378    /// Calling this method consumes the guard and prevents the Drop-based release.
379    ///
380    /// # Returns
381    /// * `Ok(true)` - Lock was successfully released
382    /// * `Ok(false)` - Lock was not released (already expired or owned by another instance)
383    /// * `Err(_)` - Redis communication error
384    pub async fn release(mut self) -> Result<bool> {
385        // Take the release info to prevent Drop from also trying to release
386        let info = self
387            .release_info
388            .take()
389            .expect("release_info should always be Some before release");
390
391        Self::do_release(info).await
392    }
393
394    /// Internal async release implementation.
395    async fn do_release(info: LockReleaseInfo) -> Result<bool> {
396        let mut conn = info.pool.get().await?;
397
398        // Lua script to atomically check and delete
399        // Only delete if the value matches (we still own the lock)
400        let script = r#"
401            if redis.call("GET", KEYS[1]) == ARGV[1] then
402                return redis.call("DEL", KEYS[1])
403            else
404                return 0
405            end
406        "#;
407
408        let result: i32 = redis::Script::new(script)
409            .key(&info.lock_key)
410            .arg(&info.lock_value)
411            .invoke_async(&mut conn)
412            .await?;
413
414        if result == 1 {
415            debug!(lock_key = %info.lock_key, "distributed lock released");
416            Ok(true)
417        } else {
418            warn!(
419                lock_key = %info.lock_key,
420                "distributed lock release failed - lock already expired or owned by another instance"
421            );
422            Ok(false)
423        }
424    }
425}
426
427impl Drop for LockGuard {
428    fn drop(&mut self) {
429        // If release_info is still Some, we need to release the lock
430        if let Some(info) = self.release_info.take() {
431            // Spawn a task to release the lock asynchronously
432            // This is best-effort; if it fails, the lock will expire after TTL
433            tokio::spawn(async move {
434                if let Err(e) = LockGuard::do_release(info).await {
435                    warn!(error = %e, "failed to release distributed lock in drop, will expire after TTL");
436                }
437            });
438        }
439    }
440}
441
442/// Generates a unique value for lock ownership verification.
443///
444/// Uses a combination of process ID, timestamp, and a monotonic counter
445/// to create a unique identifier for this lock acquisition. This avoids
446/// collisions in the same process even when calls share a timestamp.
447fn generate_lock_value() -> String {
448    use std::sync::atomic::{AtomicU64, Ordering};
449
450    static LOCK_VALUE_COUNTER: AtomicU64 = AtomicU64::new(0);
451    let process_id = std::process::id();
452    let timestamp = std::time::SystemTime::now()
453        .duration_since(std::time::UNIX_EPOCH)
454        .map(|d| d.as_nanos())
455        .unwrap_or(0);
456    let counter = LOCK_VALUE_COUNTER.fetch_add(1, Ordering::Relaxed);
457
458    format!("{process_id}:{timestamp}:{counter}")
459}
460
461// ============================================================================
462// Relayer Sync Metadata Functions
463// ============================================================================
464//
465// These functions track when relayers were last synchronized/initialized.
466// This allows multiple instances to skip redundant initialization when
467// a relayer was recently synced by another instance.
468
469/// The Redis hash key suffix for storing relayer sync metadata.
470const RELAYER_SYNC_META_KEY: &str = "relayer_sync_meta";
471
472/// The hash field for global initialization completion timestamp.
473const GLOBAL_INIT_FIELD: &str = "global:init_completed";
474
475/// The Redis hash key suffix for storing bootstrap process metadata.
476const BOOTSTRAP_META_KEY: &str = "bootstrap_meta";
477
478/// Hash field storing config processing status.
479const CONFIG_PROCESSING_STATUS_FIELD: &str = "config_processing:status";
480
481/// Hash field storing config processing start timestamp.
482const CONFIG_PROCESSING_STARTED_AT_FIELD: &str = "config_processing:started_at";
483
484/// Hash field storing config processing completion timestamp.
485const CONFIG_PROCESSING_COMPLETED_AT_FIELD: &str = "config_processing:completed_at";
486
487const CONFIG_PROCESSING_STATUS_IN_PROGRESS: &str = "in_progress";
488const CONFIG_PROCESSING_STATUS_COMPLETED: &str = "completed";
489
490/// Marks config processing as in progress.
491///
492/// This explicit marker is used by distributed bootstrap coordination so
493/// waiters do not infer completion from partially written repository state.
494pub async fn set_config_processing_in_progress(pool: &Arc<Pool>, prefix: &str) -> Result<()> {
495    use chrono::Utc;
496    use redis::AsyncCommands;
497
498    let mut conn = pool.get().await?;
499    let hash_key = format!("{prefix}:{BOOTSTRAP_META_KEY}");
500    let timestamp = Utc::now().timestamp();
501
502    conn.hset_multiple::<_, _, _, ()>(
503        &hash_key,
504        &[
505            (
506                CONFIG_PROCESSING_STATUS_FIELD,
507                CONFIG_PROCESSING_STATUS_IN_PROGRESS,
508            ),
509            (CONFIG_PROCESSING_STARTED_AT_FIELD, &timestamp.to_string()),
510        ],
511    )
512    .await
513    .map_err(|e| eyre::eyre!("Failed to set config processing in progress: {}", e))?;
514
515    conn.hdel::<_, _, ()>(&hash_key, CONFIG_PROCESSING_COMPLETED_AT_FIELD)
516        .await
517        .map_err(|e| eyre::eyre!("Failed to clear config processing completion time: {}", e))?;
518
519    debug!(
520        timestamp = %timestamp,
521        "recorded config processing in-progress marker"
522    );
523
524    Ok(())
525}
526
527/// Marks config processing as completed.
528pub async fn set_config_processing_completed(pool: &Arc<Pool>, prefix: &str) -> Result<()> {
529    use chrono::Utc;
530    use redis::AsyncCommands;
531
532    let mut conn = pool.get().await?;
533    let hash_key = format!("{prefix}:{BOOTSTRAP_META_KEY}");
534    let timestamp = Utc::now().timestamp();
535
536    conn.hset_multiple::<_, _, _, ()>(
537        &hash_key,
538        &[
539            (
540                CONFIG_PROCESSING_STATUS_FIELD,
541                CONFIG_PROCESSING_STATUS_COMPLETED,
542            ),
543            (CONFIG_PROCESSING_COMPLETED_AT_FIELD, &timestamp.to_string()),
544        ],
545    )
546    .await
547    .map_err(|e| eyre::eyre!("Failed to set config processing completed: {}", e))?;
548
549    debug!(
550        timestamp = %timestamp,
551        "recorded config processing completed marker"
552    );
553
554    Ok(())
555}
556
557/// Returns whether config processing has been explicitly marked as completed.
558pub async fn is_config_processing_completed(pool: &Arc<Pool>, prefix: &str) -> Result<bool> {
559    use redis::AsyncCommands;
560
561    let mut conn = pool.get().await?;
562    let hash_key = format!("{prefix}:{BOOTSTRAP_META_KEY}");
563
564    let status: Option<String> = conn
565        .hget(&hash_key, CONFIG_PROCESSING_STATUS_FIELD)
566        .await
567        .map_err(|e| eyre::eyre!("Failed to get config processing status: {}", e))?;
568
569    Ok(matches!(
570        status.as_deref(),
571        Some(CONFIG_PROCESSING_STATUS_COMPLETED)
572    ))
573}
574
575/// Returns whether config processing is explicitly marked as in progress.
576pub async fn is_config_processing_in_progress(pool: &Arc<Pool>, prefix: &str) -> Result<bool> {
577    use redis::AsyncCommands;
578
579    let mut conn = pool.get().await?;
580    let hash_key = format!("{prefix}:{BOOTSTRAP_META_KEY}");
581
582    let status: Option<String> = conn
583        .hget(&hash_key, CONFIG_PROCESSING_STATUS_FIELD)
584        .await
585        .map_err(|e| eyre::eyre!("Failed to get config processing status: {}", e))?;
586
587    Ok(matches!(
588        status.as_deref(),
589        Some(CONFIG_PROCESSING_STATUS_IN_PROGRESS)
590    ))
591}
592
593/// Sets the last sync timestamp for a relayer to the current time.
594///
595/// This should be called after a relayer has been successfully initialized
596/// to record when the initialization occurred.
597///
598/// # Arguments
599/// * `conn` - Redis connection manager
600/// * `prefix` - Key prefix for multi-tenant support
601/// * `relayer_id` - The relayer's unique identifier
602///
603/// # Redis Key Format
604/// Hash key: `{prefix}:relayer_sync_meta`
605/// Hash field: `{relayer_id}:last_sync`
606/// Value: Unix timestamp in seconds
607pub async fn set_relayer_last_sync(pool: &Arc<Pool>, prefix: &str, relayer_id: &str) -> Result<()> {
608    use chrono::Utc;
609    use redis::AsyncCommands;
610
611    let mut conn = pool.get().await?;
612    let hash_key = format!("{prefix}:{RELAYER_SYNC_META_KEY}");
613    let field = format!("{relayer_id}:last_sync");
614    let timestamp = Utc::now().timestamp();
615
616    conn.hset::<_, _, _, ()>(&hash_key, &field, timestamp)
617        .await
618        .map_err(|e| eyre::eyre!("Failed to set relayer last sync: {}", e))?;
619
620    debug!(
621        relayer_id = %relayer_id,
622        timestamp = %timestamp,
623        "recorded relayer last sync time"
624    );
625
626    Ok(())
627}
628
629/// Gets the last sync timestamp for a relayer.
630///
631/// # Arguments
632/// * `conn` - Redis connection manager
633/// * `prefix` - Key prefix for multi-tenant support
634/// * `relayer_id` - The relayer's unique identifier
635///
636/// # Returns
637/// * `Ok(Some(DateTime))` - The last sync time if recorded
638/// * `Ok(None)` - If the relayer has never been synced
639/// * `Err(_)` - Redis communication error
640pub async fn get_relayer_last_sync(
641    pool: &Arc<Pool>,
642    prefix: &str,
643    relayer_id: &str,
644) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
645    use chrono::{TimeZone, Utc};
646    use redis::AsyncCommands;
647
648    let mut conn = pool.get().await?;
649    let hash_key = format!("{prefix}:{RELAYER_SYNC_META_KEY}");
650    let field = format!("{relayer_id}:last_sync");
651
652    let timestamp: Option<i64> = conn
653        .hget(&hash_key, &field)
654        .await
655        .map_err(|e| eyre::eyre!("Failed to get relayer last sync: {}", e))?;
656
657    Ok(timestamp.and_then(|ts| Utc.timestamp_opt(ts, 0).single()))
658}
659
660/// Checks if a relayer was recently synced within the given threshold.
661///
662/// This is used to skip initialization for relayers that were recently
663/// initialized by another instance (e.g., during rolling restarts).
664///
665/// # Arguments
666/// * `conn` - Redis connection manager
667/// * `prefix` - Key prefix for multi-tenant support
668/// * `relayer_id` - The relayer's unique identifier
669/// * `threshold_secs` - Number of seconds to consider as "recent"
670///
671/// # Returns
672/// * `Ok(true)` - If the relayer was synced within the threshold
673/// * `Ok(false)` - If the relayer was not synced or sync is stale
674/// * `Err(_)` - Redis communication error
675pub async fn is_relayer_recently_synced(
676    pool: &Arc<Pool>,
677    prefix: &str,
678    relayer_id: &str,
679    threshold_secs: u64,
680) -> Result<bool> {
681    use chrono::Utc;
682
683    let last_sync = get_relayer_last_sync(pool, prefix, relayer_id).await?;
684
685    match last_sync {
686        Some(sync_time) => {
687            let elapsed = Utc::now().signed_duration_since(sync_time);
688            let is_recent = elapsed.num_seconds() < threshold_secs as i64;
689
690            if is_recent {
691                debug!(
692                    relayer_id = %relayer_id,
693                    last_sync = %sync_time.to_rfc3339(),
694                    elapsed_secs = %elapsed.num_seconds(),
695                    threshold_secs = %threshold_secs,
696                    "relayer was recently synced"
697                );
698            }
699
700            Ok(is_recent)
701        }
702        None => Ok(false),
703    }
704}
705
706// ============================================================================
707// Global Initialization Tracking Functions
708// ============================================================================
709//
710// These functions track when the global relayer initialization was last completed.
711// This allows multiple instances to skip redundant initialization when
712// initialization was recently completed by another instance.
713
714/// Sets the global initialization completion timestamp to the current time.
715///
716/// This should be called after all relayers have been successfully initialized
717/// to record when the initialization occurred.
718///
719/// # Arguments
720/// * `conn` - Redis connection manager
721/// * `prefix` - Key prefix for multi-tenant support
722///
723/// # Redis Key Format
724/// Hash key: `{prefix}:relayer_sync_meta`
725/// Hash field: `global:init_completed`
726/// Value: Unix timestamp in seconds
727pub async fn set_global_init_completed(pool: &Arc<Pool>, prefix: &str) -> Result<()> {
728    use chrono::Utc;
729    use redis::AsyncCommands;
730
731    let mut conn = pool.get().await?;
732    let hash_key = format!("{prefix}:{RELAYER_SYNC_META_KEY}");
733    let timestamp = Utc::now().timestamp();
734
735    conn.hset::<_, _, _, ()>(&hash_key, GLOBAL_INIT_FIELD, timestamp)
736        .await
737        .map_err(|e| eyre::eyre!("Failed to set global init completed: {}", e))?;
738
739    debug!(timestamp = %timestamp, "recorded global initialization completion time");
740    Ok(())
741}
742
743/// Checks if global initialization was recently completed within the given threshold.
744///
745/// This is used to skip initialization when another instance recently completed
746/// initialization (e.g., during rolling restarts).
747///
748/// # Arguments
749/// * `conn` - Redis connection manager
750/// * `prefix` - Key prefix for multi-tenant support
751/// * `threshold_secs` - Number of seconds to consider as "recent"
752///
753/// # Returns
754/// * `Ok(true)` - If initialization was completed within the threshold
755/// * `Ok(false)` - If initialization was not completed or is stale
756/// * `Err(_)` - Redis communication error
757pub async fn is_global_init_recently_completed(
758    pool: &Arc<Pool>,
759    prefix: &str,
760    threshold_secs: u64,
761) -> Result<bool> {
762    use chrono::Utc;
763    use redis::AsyncCommands;
764
765    let mut conn = pool.get().await?;
766    let hash_key = format!("{prefix}:{RELAYER_SYNC_META_KEY}");
767
768    let timestamp: Option<i64> = conn
769        .hget(&hash_key, GLOBAL_INIT_FIELD)
770        .await
771        .map_err(|e| eyre::eyre!("Failed to get global init time: {}", e))?;
772
773    match timestamp {
774        Some(ts) => {
775            let now = Utc::now().timestamp();
776            let elapsed = now - ts;
777            let is_recent = elapsed < threshold_secs as i64;
778
779            if is_recent {
780                debug!(
781                    elapsed_secs = %elapsed,
782                    threshold_secs = %threshold_secs,
783                    "global initialization recently completed"
784                );
785            }
786
787            Ok(is_recent)
788        }
789        None => Ok(false),
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn test_generate_lock_value_is_unique() {
799        let value1 = generate_lock_value();
800        let value2 = generate_lock_value();
801
802        // Values should be different due to monotonic counter
803        assert_ne!(value1, value2);
804    }
805
806    #[test]
807    fn test_generate_lock_value_contains_process_id() {
808        let value = generate_lock_value();
809
810        // Should contain a colon separator
811        assert!(value.contains(':'));
812
813        // Should have two parts: process_id and timestamp
814        let parts: Vec<&str> = value.split(':').collect();
815        assert_eq!(parts.len(), 3);
816
817        // First part should be parseable as u32 (process ID)
818        assert!(parts[0].parse::<u32>().is_ok());
819    }
820
821    #[test]
822    fn test_should_retain_connection_keeps_young_connection() {
823        // A freshly created connection is younger than any positive max age.
824        let metrics = Metrics::default();
825        assert!(
826            should_retain_connection(&metrics, Duration::from_secs(60)),
827            "young connection should be retained"
828        );
829    }
830
831    #[test]
832    fn test_should_retain_connection_drops_over_age_connection() {
833        // Simulate a connection created well in the past → over max age → dropped.
834        // checked_sub: a bare `Instant - Duration` panics on hosts whose
835        // monotonic clock started less than 120s ago (fresh VMs/containers).
836        let Some(past) = std::time::Instant::now().checked_sub(Duration::from_secs(120)) else {
837            return;
838        };
839        let mut metrics = Metrics::default();
840        metrics.created = past;
841        assert!(
842            !should_retain_connection(&metrics, Duration::from_secs(60)),
843            "over-age connection should be dropped"
844        );
845    }
846
847    #[test]
848    fn test_distributed_lock_key_format() {
849        // Verify the lock key format: {prefix}:lock:{name}
850        let prefix = "myrelayer";
851        let lock_name = "transaction_cleanup";
852        let lock_key = format!("{prefix}:lock:{lock_name}");
853        assert_eq!(lock_key, "myrelayer:lock:transaction_cleanup");
854    }
855
856    #[test]
857    fn test_distributed_lock_key_format_with_complex_prefix() {
858        // Test with a more realistic prefix
859        let prefix = "oz-relayer-prod";
860        let lock_name = "transaction_cleanup";
861        let lock_key = format!("{prefix}:lock:{lock_name}");
862        assert_eq!(lock_key, "oz-relayer-prod:lock:transaction_cleanup");
863    }
864
865    #[test]
866    fn test_distributed_lock_key_uses_exact_key() {
867        // DistributedLock now uses the exact key provided (no automatic prefix)
868        let lock_key = "myprefix:lock:myoperation";
869        // The lock will use this key exactly as provided
870        assert_eq!(lock_key, "myprefix:lock:myoperation");
871    }
872
873    // =========================================================================
874    // RedisConnections tests
875    // =========================================================================
876
877    mod redis_connections_tests {
878        use super::*;
879
880        #[test]
881        fn test_new_single_pool_returns_same_pool_for_both() {
882            // This test verifies the backward-compatible single pool mode
883            // When no REDIS_READER_URL is set, both pools should be the same
884            let cfg = Config::from_url("redis://127.0.0.1:6379");
885            let pool = cfg
886                .builder()
887                .expect("Failed to create pool builder")
888                .max_size(16)
889                .runtime(Runtime::Tokio1)
890                .build()
891                .expect("Failed to build pool");
892            let pool = Arc::new(pool);
893
894            let connections = RedisConnections::new_single_pool(pool.clone());
895
896            // Both primary and reader should return the same pool
897            assert!(Arc::ptr_eq(connections.primary(), &pool));
898            assert!(Arc::ptr_eq(connections.reader(), &pool));
899            assert!(Arc::ptr_eq(connections.primary(), connections.reader()));
900        }
901
902        #[test]
903        fn test_redis_connections_clone() {
904            // Verify that RedisConnections can be cloned
905            let cfg = Config::from_url("redis://127.0.0.1:6379");
906            let pool = cfg
907                .builder()
908                .expect("Failed to create pool builder")
909                .max_size(16)
910                .runtime(Runtime::Tokio1)
911                .build()
912                .expect("Failed to build pool");
913            let pool = Arc::new(pool);
914
915            let connections = RedisConnections::new_single_pool(pool);
916            let cloned = connections.clone();
917
918            // Both should point to the same pools
919            assert!(Arc::ptr_eq(connections.primary(), cloned.primary()));
920            assert!(Arc::ptr_eq(connections.reader(), cloned.reader()));
921        }
922
923        #[test]
924        fn test_redis_connections_debug() {
925            // Verify Debug implementation exists
926            let cfg = Config::from_url("redis://127.0.0.1:6379");
927            let pool = cfg
928                .builder()
929                .expect("Failed to create pool builder")
930                .max_size(16)
931                .runtime(Runtime::Tokio1)
932                .build()
933                .expect("Failed to build pool");
934            let pool = Arc::new(pool);
935
936            let connections = RedisConnections::new_single_pool(pool);
937            let debug_str = format!("{connections:?}");
938
939            assert!(debug_str.contains("RedisConnections"));
940        }
941    }
942
943    // =========================================================================
944    // Integration tests - require a running Redis instance
945    // Run with: cargo test --lib redis::tests::integration -- --ignored
946    // =========================================================================
947
948    /// Helper to create a Redis connection for integration tests.
949    /// Expects Redis to be running on localhost:6379.
950    async fn create_test_redis_connection() -> Option<Arc<Pool>> {
951        let cfg = Config::from_url("redis://127.0.0.1:6379");
952        let pool = cfg
953            .builder()
954            .ok()?
955            .max_size(16)
956            .runtime(Runtime::Tokio1)
957            .build()
958            .ok()?;
959        Some(Arc::new(pool))
960    }
961
962    mod integration {
963        use super::*;
964
965        #[tokio::test]
966        #[ignore] // Requires running Redis instance
967        async fn test_distributed_lock_acquire_and_release() {
968            let conn = create_test_redis_connection()
969                .await
970                .expect("Redis connection required for this test");
971
972            let lock =
973                DistributedLock::new(conn, "test:lock:acquire_release", Duration::from_secs(60));
974
975            // Should be able to acquire the lock
976            let guard = lock.try_acquire().await.expect("Redis error");
977            assert!(guard.is_some(), "Should acquire lock when not held");
978
979            // Release the lock
980            let released = guard.unwrap().release().await.expect("Redis error");
981            assert!(released, "Should successfully release the lock");
982        }
983
984        #[tokio::test]
985        #[ignore] // Requires running Redis instance
986        async fn test_distributed_lock_prevents_double_acquisition() {
987            let conn = create_test_redis_connection()
988                .await
989                .expect("Redis connection required for this test");
990
991            let lock1 = DistributedLock::new(
992                conn.clone(),
993                "test:lock:double_acquire",
994                Duration::from_secs(60),
995            );
996            let lock2 =
997                DistributedLock::new(conn, "test:lock:double_acquire", Duration::from_secs(60));
998
999            // First acquisition should succeed
1000            let guard1 = lock1.try_acquire().await.expect("Redis error");
1001            assert!(guard1.is_some(), "First acquisition should succeed");
1002
1003            // Second acquisition should fail (lock already held)
1004            let guard2 = lock2.try_acquire().await.expect("Redis error");
1005            assert!(
1006                guard2.is_none(),
1007                "Second acquisition should fail - lock already held"
1008            );
1009
1010            // Release the first lock
1011            guard1.unwrap().release().await.expect("Redis error");
1012
1013            // Now second acquisition should succeed
1014            let guard2_retry = lock2.try_acquire().await.expect("Redis error");
1015            assert!(guard2_retry.is_some(), "Should acquire lock after release");
1016
1017            // Cleanup
1018            guard2_retry.unwrap().release().await.expect("Redis error");
1019        }
1020
1021        #[tokio::test]
1022        #[ignore] // Requires running Redis instance
1023        async fn test_distributed_lock_expires_after_ttl() {
1024            let conn = create_test_redis_connection()
1025                .await
1026                .expect("Redis connection required for this test");
1027
1028            // Use a very short TTL for testing
1029            let lock =
1030                DistributedLock::new(conn.clone(), "test:lock:ttl_expiry", Duration::from_secs(1));
1031
1032            // Acquire the lock
1033            let guard = lock.try_acquire().await.expect("Redis error");
1034            assert!(guard.is_some(), "Should acquire lock");
1035
1036            // Don't release - let it expire
1037            drop(guard);
1038
1039            // Wait for TTL to expire
1040            tokio::time::sleep(Duration::from_secs(2)).await;
1041
1042            // Should be able to acquire again after expiry
1043            let lock2 = DistributedLock::new(conn, "test:lock:ttl_expiry", Duration::from_secs(60));
1044            let guard2 = lock2.try_acquire().await.expect("Redis error");
1045            assert!(guard2.is_some(), "Should acquire lock after TTL expiry");
1046
1047            // Cleanup
1048            if let Some(g) = guard2 {
1049                g.release().await.expect("Redis error");
1050            }
1051        }
1052
1053        #[tokio::test]
1054        #[ignore] // Requires running Redis instance
1055        async fn test_distributed_lock_release_only_own_lock() {
1056            let conn = create_test_redis_connection()
1057                .await
1058                .expect("Redis connection required for this test");
1059
1060            // Use a short TTL
1061            let lock = DistributedLock::new(
1062                conn.clone(),
1063                "test:lock:release_own",
1064                Duration::from_secs(1),
1065            );
1066
1067            // Acquire the lock
1068            let guard = lock.try_acquire().await.expect("Redis error");
1069            assert!(guard.is_some(), "Should acquire lock");
1070
1071            // Wait for lock to expire
1072            tokio::time::sleep(Duration::from_secs(2)).await;
1073
1074            // Try to release expired lock - should return false (not owned anymore)
1075            let released = guard.unwrap().release().await.expect("Redis error");
1076            assert!(!released, "Should not release expired lock");
1077        }
1078
1079        #[tokio::test]
1080        #[ignore] // Requires running Redis instance
1081        async fn test_distributed_lock_with_prefix() {
1082            let conn = create_test_redis_connection()
1083                .await
1084                .expect("Redis connection required for this test");
1085
1086            // Simulate prefixed lock key like in transaction cleanup
1087            // Format: {prefix}:lock:{name}
1088            let prefix = "test_prefix";
1089            let lock_name = "cleanup";
1090            let lock_key = format!("{prefix}:lock:{lock_name}");
1091
1092            let lock = DistributedLock::new(conn, &lock_key, Duration::from_secs(60));
1093
1094            let guard = lock.try_acquire().await.expect("Redis error");
1095            assert!(guard.is_some(), "Should acquire prefixed lock");
1096
1097            // Cleanup
1098            guard.unwrap().release().await.expect("Redis error");
1099        }
1100
1101        #[tokio::test]
1102        #[ignore] // Requires running Redis instance
1103        async fn test_distributed_lock_drop_releases_lock() {
1104            let conn = create_test_redis_connection()
1105                .await
1106                .expect("Redis connection required for this test");
1107
1108            let lock_key = "test:lock:drop_release";
1109
1110            // Acquire lock in inner scope
1111            {
1112                let lock = DistributedLock::new(conn.clone(), lock_key, Duration::from_secs(60));
1113                let guard = lock.try_acquire().await.expect("Redis error");
1114                assert!(guard.is_some(), "Should acquire lock");
1115
1116                // Guard is dropped here without explicit release()
1117            }
1118
1119            // Give the spawned release task time to complete
1120            tokio::time::sleep(Duration::from_millis(100)).await;
1121
1122            // Should be able to acquire again because Drop released the lock
1123            let lock2 = DistributedLock::new(conn, lock_key, Duration::from_secs(60));
1124            let guard2 = lock2.try_acquire().await.expect("Redis error");
1125            assert!(
1126                guard2.is_some(),
1127                "Should acquire lock after previous guard was dropped"
1128            );
1129
1130            // Cleanup
1131            if let Some(g) = guard2 {
1132                g.release().await.expect("Redis error");
1133            }
1134        }
1135
1136        #[tokio::test]
1137        #[ignore] // Requires running Redis instance
1138        async fn test_distributed_lock_explicit_release_prevents_double_release() {
1139            let conn = create_test_redis_connection()
1140                .await
1141                .expect("Redis connection required for this test");
1142
1143            let lock_key = "test:lock:no_double_release";
1144            let lock = DistributedLock::new(conn.clone(), lock_key, Duration::from_secs(60));
1145
1146            let guard = lock.try_acquire().await.expect("Redis error");
1147            assert!(guard.is_some(), "Should acquire lock");
1148
1149            // Explicitly release
1150            let released = guard.unwrap().release().await.expect("Redis error");
1151            assert!(released, "Should release successfully");
1152
1153            // Guard is now consumed by release(), Drop won't run
1154            // Verify lock is still released (not double-locked)
1155            tokio::time::sleep(Duration::from_millis(50)).await;
1156
1157            let lock2 = DistributedLock::new(conn, lock_key, Duration::from_secs(60));
1158            let guard2 = lock2.try_acquire().await.expect("Redis error");
1159            assert!(
1160                guard2.is_some(),
1161                "Should acquire lock - no double-release issue"
1162            );
1163
1164            // Cleanup
1165            if let Some(g) = guard2 {
1166                g.release().await.expect("Redis error");
1167            }
1168        }
1169
1170        #[tokio::test]
1171        #[ignore] // Requires running Redis instance
1172        async fn test_distributed_lock_drop_on_early_return() {
1173            let conn = create_test_redis_connection()
1174                .await
1175                .expect("Redis connection required for this test");
1176
1177            let lock_key = "test:lock:early_return";
1178
1179            // Simulate a function that returns early (like ? operator)
1180            async fn simulated_work_with_early_return(
1181                conn: Arc<Pool>,
1182                lock_key: &str,
1183            ) -> Result<(), &'static str> {
1184                let lock = DistributedLock::new(conn, lock_key, Duration::from_secs(60));
1185                let _guard = lock
1186                    .try_acquire()
1187                    .await
1188                    .map_err(|_| "lock error")?
1189                    .ok_or("lock held")?;
1190
1191                // Simulate early return (error path)
1192                Err("simulated error")
1193
1194                // _guard is dropped here due to early return
1195            }
1196
1197            // Call the function that returns early
1198            let result = simulated_work_with_early_return(conn.clone(), lock_key).await;
1199            assert!(result.is_err(), "Should have returned early with error");
1200
1201            // Give the spawned release task time to complete
1202            tokio::time::sleep(Duration::from_millis(100)).await;
1203
1204            // Lock should be released despite the early return
1205            let lock2 = DistributedLock::new(conn, lock_key, Duration::from_secs(60));
1206            let guard2 = lock2.try_acquire().await.expect("Redis error");
1207            assert!(
1208                guard2.is_some(),
1209                "Should acquire lock after early return released it"
1210            );
1211
1212            // Cleanup
1213            if let Some(g) = guard2 {
1214                g.release().await.expect("Redis error");
1215            }
1216        }
1217
1218        // =========================================================================
1219        // RedisConnections integration tests
1220        // =========================================================================
1221
1222        #[tokio::test]
1223        #[ignore] // Requires running Redis instance
1224        async fn test_redis_connections_single_pool_operations() {
1225            let pool = create_test_redis_connection()
1226                .await
1227                .expect("Redis connection required for this test");
1228
1229            let connections = RedisConnections::new_single_pool(pool);
1230
1231            // Test that we can get connections from both pools
1232            let mut primary_conn = connections
1233                .primary()
1234                .get()
1235                .await
1236                .expect("Failed to get primary connection");
1237            let mut reader_conn = connections
1238                .reader()
1239                .get()
1240                .await
1241                .expect("Failed to get reader connection");
1242
1243            // Test basic operations on both connections
1244            let _: () = redis::cmd("SET")
1245                .arg("test:connections:key")
1246                .arg("test_value")
1247                .query_async(&mut primary_conn)
1248                .await
1249                .expect("Failed to SET");
1250
1251            let value: String = redis::cmd("GET")
1252                .arg("test:connections:key")
1253                .query_async(&mut reader_conn)
1254                .await
1255                .expect("Failed to GET");
1256
1257            assert_eq!(value, "test_value");
1258
1259            // Cleanup
1260            let _: () = redis::cmd("DEL")
1261                .arg("test:connections:key")
1262                .query_async(&mut primary_conn)
1263                .await
1264                .expect("Failed to DEL");
1265        }
1266
1267        #[tokio::test]
1268        #[ignore] // Requires running Redis instance
1269        async fn test_redis_connections_backward_compatible() {
1270            // Verify that single pool mode (no reader URL) works correctly
1271            let pool = create_test_redis_connection()
1272                .await
1273                .expect("Redis connection required for this test");
1274
1275            let connections = Arc::new(RedisConnections::new_single_pool(pool));
1276
1277            // Multiple repositories should be able to share the connections
1278            let conn1 = connections.clone();
1279            let conn2 = connections.clone();
1280
1281            // Both should be able to get connections
1282            let _primary1 = conn1.primary().get().await.expect("Failed to get primary");
1283            let _reader1 = conn1.reader().get().await.expect("Failed to get reader");
1284            let _primary2 = conn2.primary().get().await.expect("Failed to get primary");
1285            let _reader2 = conn2.reader().get().await.expect("Failed to get reader");
1286
1287            // All should work without issues (backward compatible)
1288        }
1289        // =====================================================================
1290        // Relayer Sync Metadata Tests
1291        // =====================================================================
1292
1293        #[tokio::test]
1294        #[ignore] // Requires running Redis instance
1295        async fn test_set_and_get_relayer_last_sync() {
1296            let conn = create_test_redis_connection()
1297                .await
1298                .expect("Redis connection required for this test");
1299
1300            let prefix = "test_sync";
1301            let relayer_id = "test-relayer-sync";
1302
1303            // Set the last sync time
1304            set_relayer_last_sync(&conn, prefix, relayer_id)
1305                .await
1306                .expect("Should set last sync time");
1307
1308            // Get the last sync time
1309            let last_sync = get_relayer_last_sync(&conn, prefix, relayer_id)
1310                .await
1311                .expect("Should get last sync time");
1312
1313            assert!(last_sync.is_some(), "Should have a last sync time");
1314
1315            // Verify the timestamp is recent (within last minute)
1316            let elapsed = chrono::Utc::now()
1317                .signed_duration_since(last_sync.unwrap())
1318                .num_seconds();
1319            assert!(elapsed < 60, "Last sync should be within last minute");
1320
1321            // Cleanup: delete the hash
1322            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1323            let hash_key = format!("{prefix}:relayer_sync_meta");
1324            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1325                .await
1326                .expect("Cleanup failed");
1327        }
1328
1329        #[tokio::test]
1330        #[ignore] // Requires running Redis instance
1331        async fn test_get_relayer_last_sync_returns_none_when_not_set() {
1332            let conn = create_test_redis_connection()
1333                .await
1334                .expect("Redis connection required for this test");
1335
1336            let prefix = "test_sync_none";
1337            let relayer_id = "nonexistent-relayer";
1338
1339            // Get the last sync time for a relayer that hasn't been synced
1340            let last_sync = get_relayer_last_sync(&conn, prefix, relayer_id)
1341                .await
1342                .expect("Should not error");
1343
1344            assert!(
1345                last_sync.is_none(),
1346                "Should return None for unsynced relayer"
1347            );
1348        }
1349
1350        #[tokio::test]
1351        #[ignore] // Requires running Redis instance
1352        async fn test_is_relayer_recently_synced_returns_true_for_recent_sync() {
1353            let conn = create_test_redis_connection()
1354                .await
1355                .expect("Redis connection required for this test");
1356
1357            let prefix = "test_recent_sync";
1358            let relayer_id = "recent-relayer";
1359
1360            // Set the last sync time
1361            set_relayer_last_sync(&conn, prefix, relayer_id)
1362                .await
1363                .expect("Should set last sync time");
1364
1365            // Check if recently synced (within 5 minutes)
1366            let is_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, 300)
1367                .await
1368                .expect("Should check recent sync");
1369
1370            assert!(is_recent, "Should be recently synced");
1371
1372            // Cleanup
1373            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1374            let hash_key = format!("{prefix}:relayer_sync_meta");
1375            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1376                .await
1377                .expect("Cleanup failed");
1378        }
1379
1380        #[tokio::test]
1381        #[ignore] // Requires running Redis instance
1382        async fn test_is_relayer_recently_synced_returns_false_when_not_set() {
1383            let conn = create_test_redis_connection()
1384                .await
1385                .expect("Redis connection required for this test");
1386
1387            let prefix = "test_not_synced";
1388            let relayer_id = "never-synced-relayer";
1389
1390            // Check if recently synced for a relayer that hasn't been synced
1391            let is_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, 300)
1392                .await
1393                .expect("Should check recent sync");
1394
1395            assert!(!is_recent, "Should not be recently synced");
1396        }
1397
1398        #[tokio::test]
1399        #[ignore] // Requires running Redis instance
1400        async fn test_is_relayer_recently_synced_returns_false_for_stale_sync() {
1401            let conn = create_test_redis_connection()
1402                .await
1403                .expect("Redis connection required for this test");
1404
1405            let prefix = "test_stale_sync";
1406            let relayer_id = "stale-relayer";
1407
1408            // Manually set an old timestamp (10 minutes ago)
1409            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1410            let hash_key = format!("{prefix}:relayer_sync_meta");
1411            let field = format!("{relayer_id}:last_sync");
1412            let old_timestamp = chrono::Utc::now().timestamp() - 600; // 10 minutes ago
1413
1414            redis::AsyncCommands::hset::<_, _, _, ()>(
1415                &mut conn_clone,
1416                &hash_key,
1417                &field,
1418                old_timestamp,
1419            )
1420            .await
1421            .expect("Should set old timestamp");
1422
1423            // Check if recently synced with 5 minute threshold
1424            let is_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, 300)
1425                .await
1426                .expect("Should check recent sync");
1427
1428            assert!(!is_recent, "Should not be recently synced (stale)");
1429
1430            // Cleanup
1431            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1432                .await
1433                .expect("Cleanup failed");
1434        }
1435
1436        #[tokio::test]
1437        #[ignore] // Requires running Redis instance
1438        async fn test_get_relayer_last_sync_multiple_relayers() {
1439            let conn = create_test_redis_connection()
1440                .await
1441                .expect("Redis connection required for this test");
1442
1443            let prefix = "test_multi_sync";
1444
1445            // Set sync times for multiple relayers
1446            set_relayer_last_sync(&conn, prefix, "relayer-1")
1447                .await
1448                .expect("Should set sync time");
1449            tokio::time::sleep(Duration::from_millis(10)).await;
1450            set_relayer_last_sync(&conn, prefix, "relayer-2")
1451                .await
1452                .expect("Should set sync time");
1453
1454            let sync1 = get_relayer_last_sync(&conn, prefix, "relayer-1")
1455                .await
1456                .expect("Should get sync time");
1457            let sync2 = get_relayer_last_sync(&conn, prefix, "relayer-2")
1458                .await
1459                .expect("Should get sync time");
1460
1461            assert!(sync1.is_some(), "Relayer 1 should have sync time");
1462            assert!(sync2.is_some(), "Relayer 2 should have sync time");
1463            assert!(
1464                sync2.unwrap() >= sync1.unwrap(),
1465                "Relayer 2 should be synced at same time or after relayer 1"
1466            );
1467
1468            // Cleanup
1469            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1470            let hash_key = format!("{prefix}:relayer_sync_meta");
1471            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1472                .await
1473                .expect("Cleanup failed");
1474        }
1475
1476        #[tokio::test]
1477        #[ignore] // Requires running Redis instance
1478        async fn test_get_relayer_last_sync_update_existing() {
1479            let conn = create_test_redis_connection()
1480                .await
1481                .expect("Redis connection required for this test");
1482
1483            let prefix = "test_update_sync";
1484            let relayer_id = "update-relayer";
1485
1486            // Set initial sync time
1487            set_relayer_last_sync(&conn, prefix, relayer_id)
1488                .await
1489                .expect("Should set sync time");
1490
1491            let first_sync = get_relayer_last_sync(&conn, prefix, relayer_id)
1492                .await
1493                .expect("Should get sync time")
1494                .expect("Should have sync time");
1495
1496            // Wait and update
1497            tokio::time::sleep(Duration::from_millis(100)).await;
1498
1499            set_relayer_last_sync(&conn, prefix, relayer_id)
1500                .await
1501                .expect("Should update sync time");
1502
1503            let second_sync = get_relayer_last_sync(&conn, prefix, relayer_id)
1504                .await
1505                .expect("Should get sync time")
1506                .expect("Should have sync time");
1507
1508            assert!(
1509                second_sync >= first_sync,
1510                "Updated sync time should be at the same time or later than first"
1511            );
1512
1513            // Cleanup
1514            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1515            let hash_key = format!("{prefix}:relayer_sync_meta");
1516            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1517                .await
1518                .expect("Cleanup failed");
1519        }
1520
1521        #[tokio::test]
1522        #[ignore] // Requires running Redis instance
1523        async fn test_is_relayer_recently_synced_threshold_boundary() {
1524            let conn = create_test_redis_connection()
1525                .await
1526                .expect("Redis connection required for this test");
1527
1528            let prefix = "test_boundary_sync";
1529            let relayer_id = "boundary-relayer";
1530
1531            // Set timestamp exactly at threshold (should be considered NOT recent)
1532            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1533            let hash_key = format!("{prefix}:relayer_sync_meta");
1534            let field = format!("{relayer_id}:last_sync");
1535            let threshold_secs: u64 = 60;
1536            let boundary_timestamp = chrono::Utc::now().timestamp() - (threshold_secs as i64);
1537
1538            redis::AsyncCommands::hset::<_, _, _, ()>(
1539                &mut conn_clone,
1540                &hash_key,
1541                &field,
1542                boundary_timestamp,
1543            )
1544            .await
1545            .expect("Should set boundary timestamp");
1546
1547            let is_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, threshold_secs)
1548                .await
1549                .expect("Should check recent sync");
1550
1551            assert!(!is_recent, "Should not be recent at exact threshold");
1552
1553            // Cleanup
1554            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1555                .await
1556                .expect("Cleanup failed");
1557        }
1558
1559        #[tokio::test]
1560        #[ignore] // Requires running Redis instance
1561        async fn test_is_relayer_recently_synced_just_before_threshold() {
1562            let conn = create_test_redis_connection()
1563                .await
1564                .expect("Redis connection required for this test");
1565
1566            let prefix = "test_just_before_sync";
1567            let relayer_id = "just-before-relayer";
1568
1569            // Set timestamp just before threshold (should be considered recent)
1570            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1571            let hash_key = format!("{prefix}:relayer_sync_meta");
1572            let field = format!("{relayer_id}:last_sync");
1573            let threshold_secs: u64 = 60;
1574            let just_before_timestamp =
1575                chrono::Utc::now().timestamp() - (threshold_secs as i64) + 5;
1576
1577            redis::AsyncCommands::hset::<_, _, _, ()>(
1578                &mut conn_clone,
1579                &hash_key,
1580                &field,
1581                just_before_timestamp,
1582            )
1583            .await
1584            .expect("Should set just-before timestamp");
1585
1586            let is_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, threshold_secs)
1587                .await
1588                .expect("Should check recent sync");
1589
1590            assert!(is_recent, "Should be recent just before threshold");
1591
1592            // Cleanup
1593            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1594                .await
1595                .expect("Cleanup failed");
1596        }
1597
1598        #[tokio::test]
1599        #[ignore] // Requires running Redis instance
1600        async fn test_is_relayer_recently_synced_different_prefixes() {
1601            let conn = create_test_redis_connection()
1602                .await
1603                .expect("Redis connection required for this test");
1604
1605            let relayer_id = "shared-relayer";
1606
1607            // Set sync for prefix1 only
1608            set_relayer_last_sync(&conn, "prefix1", relayer_id)
1609                .await
1610                .expect("Should set sync time");
1611
1612            let is_recent_prefix1 = is_relayer_recently_synced(&conn, "prefix1", relayer_id, 300)
1613                .await
1614                .expect("Should check sync");
1615            let is_recent_prefix2 = is_relayer_recently_synced(&conn, "prefix2", relayer_id, 300)
1616                .await
1617                .expect("Should check sync");
1618
1619            assert!(is_recent_prefix1, "Should be recent for prefix1");
1620            assert!(!is_recent_prefix2, "Should not be recent for prefix2");
1621
1622            // Cleanup
1623            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1624            let hash_key = "prefix1:relayer_sync_meta";
1625            let _: () = redis::AsyncCommands::del(&mut conn_clone, hash_key)
1626                .await
1627                .expect("Cleanup failed");
1628        }
1629
1630        #[tokio::test]
1631        #[ignore] // Requires running Redis instance
1632        async fn test_is_relayer_recently_synced_zero_threshold() {
1633            let conn = create_test_redis_connection()
1634                .await
1635                .expect("Redis connection required for this test");
1636
1637            let prefix = "test_zero_threshold";
1638            let relayer_id = "zero-threshold-relayer";
1639
1640            set_relayer_last_sync(&conn, prefix, relayer_id)
1641                .await
1642                .expect("Should set sync time");
1643
1644            // With zero threshold, even immediate sync should be considered stale
1645            let is_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, 0)
1646                .await
1647                .expect("Should check sync");
1648
1649            assert!(!is_recent, "Should not be recent with zero threshold");
1650
1651            // Cleanup
1652            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1653            let hash_key = format!("{prefix}:relayer_sync_meta");
1654            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1655                .await
1656                .expect("Cleanup failed");
1657        }
1658
1659        // =====================================================================
1660        // Global Initialization Tracking Tests
1661        // =====================================================================
1662
1663        #[tokio::test]
1664        #[ignore] // Requires running Redis instance
1665        async fn test_set_and_check_global_init_completed() {
1666            let conn = create_test_redis_connection()
1667                .await
1668                .expect("Redis connection required for this test");
1669
1670            let prefix = "test_global_init";
1671
1672            // Set global init completed
1673            set_global_init_completed(&conn, prefix)
1674                .await
1675                .expect("Should set global init completed");
1676
1677            // Check if recently completed (within 5 minutes)
1678            let is_recent = is_global_init_recently_completed(&conn, prefix, 300)
1679                .await
1680                .expect("Should check global init");
1681
1682            assert!(is_recent, "Should be recently completed");
1683
1684            // Cleanup
1685            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1686            let hash_key = format!("{prefix}:relayer_sync_meta");
1687            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1688                .await
1689                .expect("Cleanup failed");
1690        }
1691
1692        #[tokio::test]
1693        #[ignore] // Requires running Redis instance
1694        async fn test_is_global_init_recently_completed_returns_false_when_not_set() {
1695            let conn = create_test_redis_connection()
1696                .await
1697                .expect("Redis connection required for this test");
1698
1699            let prefix = "test_global_init_not_set";
1700
1701            // Check without setting - should return false
1702            let is_recent = is_global_init_recently_completed(&conn, prefix, 300)
1703                .await
1704                .expect("Should check global init");
1705
1706            assert!(!is_recent, "Should not be recently completed when not set");
1707        }
1708
1709        #[tokio::test]
1710        #[ignore] // Requires running Redis instance
1711        async fn test_is_global_init_recently_completed_returns_false_when_stale() {
1712            let conn = create_test_redis_connection()
1713                .await
1714                .expect("Redis connection required for this test");
1715
1716            let prefix = "test_global_init_stale";
1717
1718            // Manually set an old timestamp (10 minutes ago)
1719            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1720            let hash_key = format!("{prefix}:relayer_sync_meta");
1721            let old_timestamp = chrono::Utc::now().timestamp() - 600; // 10 minutes ago
1722
1723            redis::AsyncCommands::hset::<_, _, _, ()>(
1724                &mut conn_clone,
1725                &hash_key,
1726                "global:init_completed",
1727                old_timestamp,
1728            )
1729            .await
1730            .expect("Should set old timestamp");
1731
1732            // Check with 5 minute threshold - should be stale
1733            let is_recent = is_global_init_recently_completed(&conn, prefix, 300)
1734                .await
1735                .expect("Should check global init");
1736
1737            assert!(!is_recent, "Should not be recent when stale");
1738
1739            // Cleanup
1740            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1741                .await
1742                .expect("Cleanup failed");
1743        }
1744
1745        #[tokio::test]
1746        #[ignore] // Requires running Redis instance
1747        async fn test_global_init_different_prefixes() {
1748            let conn = create_test_redis_connection()
1749                .await
1750                .expect("Redis connection required for this test");
1751
1752            // Set global init for prefix1 only
1753            set_global_init_completed(&conn, "global_prefix1")
1754                .await
1755                .expect("Should set global init");
1756
1757            let is_recent_prefix1 = is_global_init_recently_completed(&conn, "global_prefix1", 300)
1758                .await
1759                .expect("Should check global init");
1760            let is_recent_prefix2 = is_global_init_recently_completed(&conn, "global_prefix2", 300)
1761                .await
1762                .expect("Should check global init");
1763
1764            assert!(is_recent_prefix1, "Should be recent for prefix1");
1765            assert!(!is_recent_prefix2, "Should not be recent for prefix2");
1766
1767            // Cleanup
1768            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1769            let hash_key = "global_prefix1:relayer_sync_meta";
1770            let _: () = redis::AsyncCommands::del(&mut conn_clone, hash_key)
1771                .await
1772                .expect("Cleanup failed");
1773        }
1774
1775        #[tokio::test]
1776        #[ignore] // Requires running Redis instance
1777        async fn test_global_init_update_existing() {
1778            let conn = create_test_redis_connection()
1779                .await
1780                .expect("Redis connection required for this test");
1781
1782            let prefix = "test_global_init_update";
1783
1784            // Set initial timestamp
1785            set_global_init_completed(&conn, prefix)
1786                .await
1787                .expect("Should set global init");
1788
1789            // Wait a bit
1790            tokio::time::sleep(Duration::from_millis(100)).await;
1791
1792            // Update timestamp
1793            set_global_init_completed(&conn, prefix)
1794                .await
1795                .expect("Should update global init");
1796
1797            // Should still be recent
1798            let is_recent = is_global_init_recently_completed(&conn, prefix, 300)
1799                .await
1800                .expect("Should check global init");
1801
1802            assert!(is_recent, "Should be recent after update");
1803
1804            // Cleanup
1805            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1806            let hash_key = format!("{prefix}:relayer_sync_meta");
1807            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1808                .await
1809                .expect("Cleanup failed");
1810        }
1811
1812        #[tokio::test]
1813        #[ignore] // Requires running Redis instance
1814        async fn test_global_init_coexists_with_relayer_sync() {
1815            let conn = create_test_redis_connection()
1816                .await
1817                .expect("Redis connection required for this test");
1818
1819            let prefix = "test_coexist";
1820            let relayer_id = "test-relayer";
1821
1822            // Set both global init and relayer sync
1823            set_global_init_completed(&conn, prefix)
1824                .await
1825                .expect("Should set global init");
1826            set_relayer_last_sync(&conn, prefix, relayer_id)
1827                .await
1828                .expect("Should set relayer sync");
1829
1830            // Both should be checkable
1831            let global_recent = is_global_init_recently_completed(&conn, prefix, 300)
1832                .await
1833                .expect("Should check global init");
1834            let relayer_recent = is_relayer_recently_synced(&conn, prefix, relayer_id, 300)
1835                .await
1836                .expect("Should check relayer sync");
1837
1838            assert!(global_recent, "Global init should be recent");
1839            assert!(relayer_recent, "Relayer sync should be recent");
1840
1841            // Cleanup
1842            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1843            let hash_key = format!("{prefix}:relayer_sync_meta");
1844            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1845                .await
1846                .expect("Cleanup failed");
1847        }
1848
1849        // =====================================================================
1850        // Config Processing Marker Tests
1851        // =====================================================================
1852
1853        #[tokio::test]
1854        #[ignore] // Requires running Redis instance
1855        async fn test_config_processing_in_progress_is_not_completed() {
1856            let conn = create_test_redis_connection()
1857                .await
1858                .expect("Redis connection required for this test");
1859
1860            let prefix = "test_config_processing_in_progress";
1861
1862            set_config_processing_in_progress(&conn, prefix)
1863                .await
1864                .expect("Should set in-progress marker");
1865
1866            let completed = is_config_processing_completed(&conn, prefix)
1867                .await
1868                .expect("Should read config processing status");
1869
1870            assert!(
1871                !completed,
1872                "In-progress config processing must not be treated as completed"
1873            );
1874
1875            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1876            let hash_key = format!("{prefix}:bootstrap_meta");
1877            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1878                .await
1879                .expect("Cleanup failed");
1880        }
1881
1882        #[tokio::test]
1883        #[ignore] // Requires running Redis instance
1884        async fn test_config_processing_completed_requires_explicit_completion_marker() {
1885            let conn = create_test_redis_connection()
1886                .await
1887                .expect("Redis connection required for this test");
1888
1889            let prefix = "test_config_processing_completed";
1890
1891            set_config_processing_in_progress(&conn, prefix)
1892                .await
1893                .expect("Should set in-progress marker");
1894            set_config_processing_completed(&conn, prefix)
1895                .await
1896                .expect("Should set completed marker");
1897
1898            let completed = is_config_processing_completed(&conn, prefix)
1899                .await
1900                .expect("Should read config processing status");
1901
1902            assert!(
1903                completed,
1904                "Completed config processing must require the explicit completion marker"
1905            );
1906
1907            let mut conn_clone = conn.get().await.expect("Failed to get connection");
1908            let hash_key = format!("{prefix}:bootstrap_meta");
1909            let _: () = redis::AsyncCommands::del(&mut conn_clone, &hash_key)
1910                .await
1911                .expect("Cleanup failed");
1912        }
1913    }
1914}