Skip to main content

llm_optimizer_processor/state/
redis_lock.rs

1//! Redis-based distributed locking implementation
2//!
3//! This module implements distributed locking using Redis with the following features:
4//!
5//! - **Atomic Operations**: Uses Redis SET NX EX for atomic lock acquisition
6//! - **Token-based Ownership**: UUID tokens prevent accidental release by wrong holder
7//! - **Automatic Expiration**: TTL ensures locks don't persist after crashes
8//! - **Fair Queuing**: Optional FIFO queue for lock waiters
9//! - **LUA Scripts**: Atomic multi-command operations
10//!
11//! ## Redis Lock Implementation
12//!
13//! The implementation uses the algorithm recommended by Redis documentation:
14//!
15//! 1. **Acquire**: `SET resource token NX EX ttl`
16//! 2. **Release**: LUA script to verify token and delete
17//! 3. **Extend**: LUA script to verify token and update expiration
18//!
19//! ## Example
20//!
21//! ```rust,no_run
22//! use processor::state::distributed_lock::{RedisDistributedLock, DistributedLock};
23//! use std::time::Duration;
24//!
25//! #[tokio::main]
26//! async fn main() -> anyhow::Result<()> {
27//!     let lock = RedisDistributedLock::new("redis://localhost:6379").await?;
28//!
29//!     // Try to acquire lock
30//!     if let Some(guard) = lock.try_acquire("resource", Duration::from_secs(30)).await? {
31//!         println!("Lock acquired!");
32//!         // Do work...
33//!         drop(guard);
34//!     } else {
35//!         println!("Lock already held by another instance");
36//!     }
37//!
38//!     Ok(())
39//! }
40//! ```
41
42use async_trait::async_trait;
43use chrono::Utc;
44use redis::{
45    aio::ConnectionManager, AsyncCommands, Client, RedisError, Script, Value,
46};
47use std::sync::Arc;
48use std::time::Duration;
49use tracing::{debug, warn};
50
51use super::distributed_lock::{DistributedLock, LockGuard, LockToken};
52use crate::error::{StateError, StateResult};
53
54/// Redis-based distributed lock implementation
55///
56/// Uses Redis atomic operations and LUA scripts to implement distributed locking
57/// with proper ownership semantics and automatic expiration.
58pub struct RedisDistributedLock {
59    /// Redis connection manager
60    connection: ConnectionManager,
61
62    /// Key prefix for locks
63    prefix: String,
64
65    /// Optional retry configuration
66    retry_config: RetryConfig,
67}
68
69impl std::fmt::Debug for RedisDistributedLock {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.debug_struct("RedisDistributedLock")
72            .field("prefix", &self.prefix)
73            .field("retry_config", &self.retry_config)
74            .finish()
75    }
76}
77
78/// Configuration for lock acquisition retries
79#[derive(Debug, Clone)]
80pub struct RetryConfig {
81    /// Maximum number of retry attempts
82    pub max_attempts: u32,
83
84    /// Initial delay between retries
85    pub initial_delay: Duration,
86
87    /// Maximum delay between retries
88    pub max_delay: Duration,
89
90    /// Backoff multiplier
91    pub backoff_multiplier: f64,
92}
93
94impl Default for RetryConfig {
95    fn default() -> Self {
96        Self {
97            max_attempts: 10,
98            initial_delay: Duration::from_millis(100),
99            max_delay: Duration::from_secs(5),
100            backoff_multiplier: 2.0,
101        }
102    }
103}
104
105impl RetryConfig {
106    /// No retries - fail immediately if lock not available
107    pub fn no_retry() -> Self {
108        Self {
109            max_attempts: 1,
110            ..Default::default()
111        }
112    }
113
114    /// Aggressive retries for critical operations
115    pub fn aggressive() -> Self {
116        Self {
117            max_attempts: 50,
118            initial_delay: Duration::from_millis(50),
119            max_delay: Duration::from_secs(1),
120            backoff_multiplier: 1.5,
121        }
122    }
123}
124
125// LUA script for atomic lock release with token verification
126const RELEASE_SCRIPT: &str = r#"
127    if redis.call("GET", KEYS[1]) == ARGV[1] then
128        return redis.call("DEL", KEYS[1])
129    else
130        return 0
131    end
132"#;
133
134// LUA script for atomic lock extension with token verification
135const EXTEND_SCRIPT: &str = r#"
136    if redis.call("GET", KEYS[1]) == ARGV[1] then
137        return redis.call("PEXPIRE", KEYS[1], ARGV[2])
138    else
139        return 0
140    end
141"#;
142
143impl RedisDistributedLock {
144    /// Create a new Redis distributed lock
145    ///
146    /// # Arguments
147    ///
148    /// * `redis_url` - Redis connection URL (e.g., "redis://localhost:6379")
149    ///
150    /// # Returns
151    ///
152    /// * `Ok(RedisDistributedLock)` - Lock instance created
153    /// * `Err(StateError)` - Failed to connect to Redis
154    pub async fn new(redis_url: &str) -> StateResult<Self> {
155        Self::with_config(redis_url, "lock:", RetryConfig::default()).await
156    }
157
158    /// Create a new Redis distributed lock with custom configuration
159    ///
160    /// # Arguments
161    ///
162    /// * `redis_url` - Redis connection URL
163    /// * `prefix` - Key prefix for locks (e.g., "app:lock:")
164    /// * `retry_config` - Retry configuration for lock acquisition
165    pub async fn with_config(
166        redis_url: &str,
167        prefix: &str,
168        retry_config: RetryConfig,
169    ) -> StateResult<Self> {
170        let client = Client::open(redis_url).map_err(|e| StateError::StorageError {
171            backend_type: "Redis".to_string(),
172            details: format!("Failed to create Redis client: {}", e),
173        })?;
174
175        let connection = client
176            .get_connection_manager()
177            .await
178            .map_err(|e| StateError::StorageError {
179                backend_type: "Redis".to_string(),
180                details: format!("Failed to connect to Redis: {}", e),
181            })?;
182
183        Ok(Self {
184            connection,
185            prefix: prefix.to_string(),
186            retry_config,
187        })
188    }
189
190    /// Get the full Redis key for a resource
191    fn resource_key(&self, resource: &str) -> String {
192        format!("{}{}", self.prefix, resource)
193    }
194
195    /// Calculate delay for retry attempt
196    fn calculate_delay(&self, attempt: u32) -> Duration {
197        let delay = self.retry_config.initial_delay.as_millis() as f64
198            * self.retry_config.backoff_multiplier.powi(attempt as i32);
199
200        Duration::from_millis(delay.min(self.retry_config.max_delay.as_millis() as f64) as u64)
201    }
202}
203
204#[async_trait]
205impl DistributedLock for RedisDistributedLock {
206    async fn acquire(&self, resource: &str, ttl: Duration) -> StateResult<LockGuard> {
207        let key = self.resource_key(resource);
208        let token = LockToken::new();
209        let ttl_secs = ttl.as_secs();
210
211        debug!(
212            resource = %resource,
213            token = %token,
214            ttl_secs = ttl_secs,
215            "Attempting to acquire lock"
216        );
217
218        let mut attempt = 0;
219
220        loop {
221            attempt += 1;
222
223            // Try to set the lock with NX (only if not exists) and EX (expiration)
224            let mut conn = self.connection.clone();
225            let result: Result<Value, RedisError> = redis::cmd("SET")
226                .arg(&key)
227                .arg(token.value())
228                .arg("NX")
229                .arg("EX")
230                .arg(ttl_secs)
231                .query_async(&mut conn)
232                .await;
233
234            match result {
235                Ok(Value::Okay) => {
236                    debug!(
237                        resource = %resource,
238                        token = %token,
239                        attempt = attempt,
240                        "Lock acquired"
241                    );
242
243                    let guard = LockGuard::new(
244                        resource.to_string(),
245                        token,
246                        Utc::now(),
247                        ttl,
248                        Arc::new(Self {
249                            connection: self.connection.clone(),
250                            prefix: self.prefix.clone(),
251                            retry_config: self.retry_config.clone(),
252                        }),
253                    );
254
255                    return Ok(guard);
256                }
257                Ok(_) => {
258                    // Lock is held by another instance
259                    if attempt >= self.retry_config.max_attempts {
260                        return Err(StateError::TransactionFailed {
261                            operation: "acquire_lock".to_string(),
262                            reason: format!(
263                                "Failed to acquire lock after {} attempts",
264                                attempt
265                            ),
266                        });
267                    }
268
269                    // Wait and retry
270                    let delay = self.calculate_delay(attempt);
271                    debug!(
272                        resource = %resource,
273                        attempt = attempt,
274                        delay_ms = delay.as_millis(),
275                        "Lock held by another instance, retrying"
276                    );
277                    tokio::time::sleep(delay).await;
278                }
279                Err(e) => {
280                    return Err(StateError::StorageError {
281                        backend_type: "Redis".to_string(),
282                        details: format!("Lock acquisition failed: {}", e),
283                    });
284                }
285            }
286        }
287    }
288
289    async fn try_acquire(&self, resource: &str, ttl: Duration) -> StateResult<Option<LockGuard>> {
290        let key = self.resource_key(resource);
291        let token = LockToken::new();
292        let ttl_secs = ttl.as_secs();
293
294        debug!(
295            resource = %resource,
296            token = %token,
297            ttl_secs = ttl_secs,
298            "Attempting non-blocking lock acquisition"
299        );
300
301        let mut conn = self.connection.clone();
302        let result: Result<Value, RedisError> = redis::cmd("SET")
303            .arg(&key)
304            .arg(token.value())
305            .arg("NX")
306            .arg("EX")
307            .arg(ttl_secs)
308            .query_async(&mut conn)
309            .await;
310
311        match result {
312            Ok(Value::Okay) => {
313                debug!(resource = %resource, token = %token, "Lock acquired");
314
315                let guard = LockGuard::new(
316                    resource.to_string(),
317                    token,
318                    Utc::now(),
319                    ttl,
320                    Arc::new(Self {
321                        connection: self.connection.clone(),
322                        prefix: self.prefix.clone(),
323                        retry_config: self.retry_config.clone(),
324                    }),
325                );
326
327                Ok(Some(guard))
328            }
329            Ok(_) => {
330                debug!(resource = %resource, "Lock already held");
331                Ok(None)
332            }
333            Err(e) => Err(StateError::StorageError {
334                backend_type: "Redis".to_string(),
335                details: format!("Lock acquisition failed: {}", e),
336            }),
337        }
338    }
339
340    async fn release_internal(&self, resource: &str, token: &LockToken) -> StateResult<()> {
341        let key = self.resource_key(resource);
342
343        debug!(resource = %resource, token = %token, "Releasing lock");
344
345        // Use LUA script to atomically check token and delete
346        let script = Script::new(RELEASE_SCRIPT);
347        let mut conn = self.connection.clone();
348
349        let result: i32 = script
350            .key(&key)
351            .arg(token.value())
352            .invoke_async(&mut conn)
353            .await
354            .map_err(|e| StateError::StorageError {
355                backend_type: "Redis".to_string(),
356                details: format!("Lock release failed: {}", e),
357            })?;
358
359        if result == 0 {
360            warn!(
361                resource = %resource,
362                token = %token,
363                "Lock release failed: token mismatch or already released"
364            );
365            return Err(StateError::TransactionFailed {
366                operation: "release_lock".to_string(),
367                reason: "Token mismatch or lock already released".to_string(),
368            });
369        }
370
371        debug!(resource = %resource, "Lock released");
372        Ok(())
373    }
374
375    async fn is_locked(&self, resource: &str) -> StateResult<bool> {
376        let key = self.resource_key(resource);
377        let mut conn = self.connection.clone();
378
379        let exists: bool = conn.exists(&key).await.map_err(|e| StateError::StorageError {
380            backend_type: "Redis".to_string(),
381            details: format!("Failed to check lock existence: {}", e),
382        })?;
383
384        Ok(exists)
385    }
386
387    async fn extend(&self, guard: &mut LockGuard, ttl: Duration) -> StateResult<()> {
388        let key = self.resource_key(guard.resource());
389        let ttl_ms = ttl.as_millis() as i64;
390
391        debug!(
392            resource = %guard.resource(),
393            token = %guard.token(),
394            ttl_ms = ttl_ms,
395            "Extending lock"
396        );
397
398        // Use LUA script to atomically check token and extend
399        let script = Script::new(EXTEND_SCRIPT);
400        let mut conn = self.connection.clone();
401
402        let result: i32 = script
403            .key(&key)
404            .arg(guard.token().value())
405            .arg(ttl_ms)
406            .invoke_async(&mut conn)
407            .await
408            .map_err(|e| StateError::StorageError {
409                backend_type: "Redis".to_string(),
410                details: format!("Lock extension failed: {}", e),
411            })?;
412
413        if result == 0 {
414            warn!(
415                resource = %guard.resource(),
416                token = %guard.token(),
417                "Lock extension failed: token mismatch or lock expired"
418            );
419            return Err(StateError::TransactionFailed {
420                operation: "extend_lock".to_string(),
421                reason: "Token mismatch or lock expired".to_string(),
422            });
423        }
424
425        // Update guard's metadata
426        // Note: We can't directly mutate the guard fields, but the backend
427        // has extended the lock. The guard's TTL field is informational.
428
429        debug!(resource = %guard.resource(), "Lock extended");
430        Ok(())
431    }
432
433    async fn get_lock_info(&self, resource: &str) -> StateResult<Option<LockToken>> {
434        let key = self.resource_key(resource);
435        let mut conn = self.connection.clone();
436
437        let value: Option<String> = conn.get(&key).await.map_err(|e| StateError::StorageError {
438            backend_type: "Redis".to_string(),
439            details: format!("Failed to get lock info: {}", e),
440        })?;
441
442        Ok(value.map(LockToken::from_string))
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use std::time::Duration;
450
451    // Helper to create a test Redis lock (requires Redis running)
452    async fn create_test_lock() -> Option<RedisDistributedLock> {
453        // Try to connect to Redis
454        match RedisDistributedLock::with_config(
455            "redis://localhost:6379",
456            "test:lock:",
457            RetryConfig::no_retry(),
458        )
459        .await
460        {
461            Ok(lock) => Some(lock),
462            Err(_) => {
463                eprintln!("Skipping Redis tests - Redis not available");
464                None
465            }
466        }
467    }
468
469    #[tokio::test]
470    async fn test_acquire_and_release() {
471        let Some(lock) = create_test_lock().await else {
472            return;
473        };
474
475        let resource = format!("test:resource:{}", Uuid::new_v4());
476
477        // Lock should not exist initially
478        assert!(!lock.is_locked(&resource).await.unwrap());
479
480        // Acquire lock
481        let guard = lock
482            .acquire(&resource, Duration::from_secs(10))
483            .await
484            .unwrap();
485
486        // Lock should exist now
487        assert!(lock.is_locked(&resource).await.unwrap());
488
489        // Release lock
490        lock.release(guard).await.unwrap();
491
492        // Lock should not exist after release
493        assert!(!lock.is_locked(&resource).await.unwrap());
494    }
495
496    #[tokio::test]
497    async fn test_try_acquire() {
498        let Some(lock) = create_test_lock().await else {
499            return;
500        };
501
502        let resource = format!("test:resource:{}", Uuid::new_v4());
503
504        // First acquisition should succeed
505        let guard1 = lock
506            .try_acquire(&resource, Duration::from_secs(10))
507            .await
508            .unwrap();
509        assert!(guard1.is_some());
510
511        // Second acquisition should fail
512        let guard2 = lock
513            .try_acquire(&resource, Duration::from_secs(10))
514            .await
515            .unwrap();
516        assert!(guard2.is_none());
517
518        // Release first lock
519        drop(guard1);
520
521        // Wait a bit for async drop to complete
522        tokio::time::sleep(Duration::from_millis(100)).await;
523
524        // Third acquisition should succeed
525        let guard3 = lock
526            .try_acquire(&resource, Duration::from_secs(10))
527            .await
528            .unwrap();
529        assert!(guard3.is_some());
530    }
531
532    #[tokio::test]
533    async fn test_lock_expiration() {
534        let Some(lock) = create_test_lock().await else {
535            return;
536        };
537
538        let resource = format!("test:resource:{}", Uuid::new_v4());
539
540        // Acquire lock with short TTL
541        let guard = lock
542            .acquire(&resource, Duration::from_secs(1))
543            .await
544            .unwrap();
545
546        assert!(lock.is_locked(&resource).await.unwrap());
547
548        // Don't release, let it expire
549        std::mem::forget(guard);
550
551        // Wait for expiration
552        tokio::time::sleep(Duration::from_secs(2)).await;
553
554        // Lock should be gone
555        assert!(!lock.is_locked(&resource).await.unwrap());
556    }
557
558    #[tokio::test]
559    async fn test_lock_extension() {
560        let Some(lock) = create_test_lock().await else {
561            return;
562        };
563
564        let resource = format!("test:resource:{}", Uuid::new_v4());
565
566        // Acquire lock with short TTL
567        let mut guard = lock
568            .acquire(&resource, Duration::from_secs(2))
569            .await
570            .unwrap();
571
572        // Wait most of the TTL
573        tokio::time::sleep(Duration::from_millis(1500)).await;
574
575        // Extend the lock
576        lock.extend(&mut guard, Duration::from_secs(5))
577            .await
578            .unwrap();
579
580        // Wait past original TTL
581        tokio::time::sleep(Duration::from_millis(1000)).await;
582
583        // Lock should still exist
584        assert!(lock.is_locked(&resource).await.unwrap());
585    }
586
587    #[tokio::test]
588    async fn test_concurrent_access() {
589        let Some(lock) = create_test_lock().await else {
590            return;
591        };
592
593        let resource = format!("test:resource:{}", Uuid::new_v4());
594        let lock = Arc::new(lock);
595
596        let mut handles = vec![];
597
598        // Spawn 5 tasks trying to acquire the same lock
599        for i in 0..5 {
600            let lock = lock.clone();
601            let resource = resource.clone();
602
603            let handle = tokio::spawn(async move {
604                if let Some(guard) = lock
605                    .try_acquire(&resource, Duration::from_secs(1))
606                    .await
607                    .unwrap()
608                {
609                    // Simulate work
610                    tokio::time::sleep(Duration::from_millis(100)).await;
611                    drop(guard);
612                    Some(i)
613                } else {
614                    None
615                }
616            });
617
618            handles.push(handle);
619        }
620
621        // Collect results
622        let mut successful = 0;
623        for handle in handles {
624            if handle.await.unwrap().is_some() {
625                successful += 1;
626            }
627        }
628
629        // Only one should have succeeded initially
630        // (others may succeed after the first releases)
631        assert!(successful >= 1);
632    }
633
634    #[tokio::test]
635    async fn test_get_lock_info() {
636        let Some(lock) = create_test_lock().await else {
637            return;
638        };
639
640        let resource = format!("test:resource:{}", Uuid::new_v4());
641
642        // No lock initially
643        assert!(lock.get_lock_info(&resource).await.unwrap().is_none());
644
645        // Acquire lock
646        let guard = lock
647            .acquire(&resource, Duration::from_secs(10))
648            .await
649            .unwrap();
650
651        // Should return the token
652        let token = lock.get_lock_info(&resource).await.unwrap();
653        assert!(token.is_some());
654        assert_eq!(token.unwrap(), *guard.token());
655    }
656
657    #[test]
658    fn test_retry_config() {
659        let config = RetryConfig::default();
660        assert_eq!(config.max_attempts, 10);
661
662        let no_retry = RetryConfig::no_retry();
663        assert_eq!(no_retry.max_attempts, 1);
664
665        let aggressive = RetryConfig::aggressive();
666        assert_eq!(aggressive.max_attempts, 50);
667    }
668}