llm_optimizer_processor/state/
postgres_lock.rs

1//! PostgreSQL-based distributed locking implementation
2//!
3//! This module implements distributed locking using PostgreSQL advisory locks.
4//! Advisory locks are a PostgreSQL feature that provides application-level locks
5//! with excellent performance and automatic cleanup on connection termination.
6//!
7//! ## Features
8//!
9//! - **Advisory Locks**: Uses PostgreSQL's built-in advisory lock system
10//! - **Session/Transaction Level**: Support for both session and transaction locks
11//! - **Automatic Cleanup**: Locks automatically released on connection close
12//! - **No External Dependencies**: Uses your existing PostgreSQL database
13//! - **Deadlock Detection**: PostgreSQL handles deadlock detection automatically
14//!
15//! ## Lock Types
16//!
17//! PostgreSQL advisory locks come in two flavors:
18//!
19//! 1. **Session Locks**: Held until explicitly released or connection closes
20//! 2. **Transaction Locks**: Automatically released at transaction end
21//!
22//! This implementation uses session locks for consistency with the trait API.
23//!
24//! ## Example
25//!
26//! ```rust,no_run
27//! use processor::state::distributed_lock::{PostgresDistributedLock, DistributedLock};
28//! use std::time::Duration;
29//!
30//! #[tokio::main]
31//! async fn main() -> anyhow::Result<()> {
32//!     let lock = PostgresDistributedLock::new("postgresql://localhost/mydb").await?;
33//!
34//!     // Acquire lock
35//!     let guard = lock.acquire("checkpoint", Duration::from_secs(30)).await?;
36//!
37//!     // Do critical work
38//!     println!("Performing checkpoint...");
39//!
40//!     // Lock automatically released on drop
41//!     drop(guard);
42//!
43//!     Ok(())
44//! }
45//! ```
46//!
47//! ## Implementation Notes
48//!
49//! PostgreSQL advisory locks use a 64-bit integer key. We hash the resource name
50//! to generate this key. While hash collisions are theoretically possible, they
51//! are extremely rare with a good hash function (we use SipHash via the standard
52//! library's hasher).
53
54use async_trait::async_trait;
55use chrono::Utc;
56use sqlx::{postgres::PgPoolOptions, PgPool, Row};
57use std::collections::hash_map::DefaultHasher;
58use std::hash::{Hash, Hasher};
59use std::sync::Arc;
60use std::time::Duration;
61use tokio::sync::RwLock;
62use tracing::{debug, warn};
63
64use super::distributed_lock::{DistributedLock, LockGuard, LockToken};
65use crate::error::{StateError, StateResult};
66
67/// PostgreSQL-based distributed lock implementation
68///
69/// Uses PostgreSQL advisory locks for distributed coordination. Advisory locks
70/// are fast, automatically cleaned up on connection termination, and support
71/// both blocking and non-blocking acquisition.
72pub struct PostgresDistributedLock {
73    /// Database connection pool
74    pool: PgPool,
75
76    /// Lock metadata storage
77    /// Maps resource name to (lock_id, token, expiry_time)
78    metadata: Arc<RwLock<std::collections::HashMap<String, (i64, LockToken, i64)>>>,
79
80    /// Background task handle for TTL monitoring
81    _monitor_handle: Option<tokio::task::JoinHandle<()>>,
82}
83
84impl std::fmt::Debug for PostgresDistributedLock {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("PostgresDistributedLock")
87            .field("metadata_count", &self.metadata.try_read().map(|m| m.len()))
88            .finish()
89    }
90}
91
92impl PostgresDistributedLock {
93    /// Create a new PostgreSQL distributed lock
94    ///
95    /// # Arguments
96    ///
97    /// * `database_url` - PostgreSQL connection URL
98    ///
99    /// # Returns
100    ///
101    /// * `Ok(PostgresDistributedLock)` - Lock instance created
102    /// * `Err(StateError)` - Failed to connect to database
103    pub async fn new(database_url: &str) -> StateResult<Self> {
104        Self::with_pool_size(database_url, 10).await
105    }
106
107    /// Create a new PostgreSQL distributed lock with custom pool size
108    ///
109    /// # Arguments
110    ///
111    /// * `database_url` - PostgreSQL connection URL
112    /// * `max_connections` - Maximum number of connections in the pool
113    pub async fn with_pool_size(database_url: &str, max_connections: u32) -> StateResult<Self> {
114        let pool = PgPoolOptions::new()
115            .max_connections(max_connections)
116            .connect(database_url)
117            .await
118            .map_err(|e| StateError::StorageError {
119                backend_type: "PostgreSQL".to_string(),
120                details: format!("Failed to connect to database: {}", e),
121            })?;
122
123        let metadata = Arc::new(RwLock::new(std::collections::HashMap::new()));
124
125        // Start background task to monitor TTLs
126        let monitor_handle = Self::start_ttl_monitor(metadata.clone(), pool.clone());
127
128        Ok(Self {
129            pool,
130            metadata,
131            _monitor_handle: Some(monitor_handle),
132        })
133    }
134
135    /// Start background task to monitor and release expired locks
136    fn start_ttl_monitor(
137        metadata: Arc<RwLock<std::collections::HashMap<String, (i64, LockToken, i64)>>>,
138        pool: PgPool,
139    ) -> tokio::task::JoinHandle<()> {
140        tokio::spawn(async move {
141            let mut interval = tokio::time::interval(Duration::from_secs(1));
142
143            loop {
144                interval.tick().await;
145
146                let now = Utc::now().timestamp_millis();
147                let mut metadata = metadata.write().await;
148                let mut expired = Vec::new();
149
150                // Find expired locks
151                for (resource, (lock_id, token, expiry)) in metadata.iter() {
152                    if *expiry <= now {
153                        expired.push((resource.clone(), *lock_id, token.clone()));
154                    }
155                }
156
157                // Release expired locks
158                for (resource, lock_id, token) in expired {
159                    debug!(
160                        resource = %resource,
161                        lock_id = lock_id,
162                        token = %token,
163                        "Releasing expired lock"
164                    );
165
166                    // Release the advisory lock
167                    let result = sqlx::query("SELECT pg_advisory_unlock($1)")
168                        .bind(lock_id)
169                        .execute(&pool)
170                        .await;
171
172                    if let Err(e) = result {
173                        warn!(
174                            resource = %resource,
175                            error = %e,
176                            "Failed to release expired lock"
177                        );
178                    }
179
180                    metadata.remove(&resource);
181                }
182            }
183        })
184    }
185
186    /// Hash resource name to lock ID
187    ///
188    /// PostgreSQL advisory locks use a 64-bit integer. We hash the resource
189    /// name to generate a consistent lock ID.
190    fn resource_to_lock_id(resource: &str) -> i64 {
191        let mut hasher = DefaultHasher::new();
192        resource.hash(&mut hasher);
193        hasher.finish() as i64
194    }
195
196    /// Calculate expiry timestamp from TTL
197    fn calculate_expiry(ttl: Duration) -> i64 {
198        Utc::now().timestamp_millis() + ttl.as_millis() as i64
199    }
200}
201
202#[async_trait]
203impl DistributedLock for PostgresDistributedLock {
204    async fn acquire(&self, resource: &str, ttl: Duration) -> StateResult<LockGuard> {
205        let lock_id = Self::resource_to_lock_id(resource);
206        let token = LockToken::new();
207        let expiry = Self::calculate_expiry(ttl);
208
209        debug!(
210            resource = %resource,
211            lock_id = lock_id,
212            token = %token,
213            ttl_ms = ttl.as_millis(),
214            "Attempting to acquire PostgreSQL advisory lock"
215        );
216
217        // Try to acquire the advisory lock (blocking)
218        // pg_advisory_lock blocks until the lock is available
219        let result = sqlx::query("SELECT pg_advisory_lock($1)")
220            .bind(lock_id)
221            .execute(&self.pool)
222            .await
223            .map_err(|e| StateError::StorageError {
224                backend_type: "PostgreSQL".to_string(),
225                details: format!("Failed to acquire advisory lock: {}", e),
226            })?;
227
228        debug!(
229            resource = %resource,
230            lock_id = lock_id,
231            "Advisory lock acquired"
232        );
233
234        // Store metadata
235        {
236            let mut metadata = self.metadata.write().await;
237            metadata.insert(resource.to_string(), (lock_id, token.clone(), expiry));
238        }
239
240        let guard = LockGuard::new(
241            resource.to_string(),
242            token,
243            Utc::now(),
244            ttl,
245            Arc::new(Self {
246                pool: self.pool.clone(),
247                metadata: self.metadata.clone(),
248                _monitor_handle: None, // Don't duplicate monitor task
249            }),
250        );
251
252        Ok(guard)
253    }
254
255    async fn try_acquire(&self, resource: &str, ttl: Duration) -> StateResult<Option<LockGuard>> {
256        let lock_id = Self::resource_to_lock_id(resource);
257        let token = LockToken::new();
258        let expiry = Self::calculate_expiry(ttl);
259
260        debug!(
261            resource = %resource,
262            lock_id = lock_id,
263            token = %token,
264            ttl_ms = ttl.as_millis(),
265            "Attempting non-blocking lock acquisition"
266        );
267
268        // Try to acquire the advisory lock (non-blocking)
269        // pg_try_advisory_lock returns immediately with true/false
270        let row = sqlx::query("SELECT pg_try_advisory_lock($1) as acquired")
271            .bind(lock_id)
272            .fetch_one(&self.pool)
273            .await
274            .map_err(|e| StateError::StorageError {
275                backend_type: "PostgreSQL".to_string(),
276                details: format!("Failed to try advisory lock: {}", e),
277            })?;
278
279        let acquired: bool = row.get("acquired");
280
281        if acquired {
282            debug!(
283                resource = %resource,
284                lock_id = lock_id,
285                "Advisory lock acquired"
286            );
287
288            // Store metadata
289            {
290                let mut metadata = self.metadata.write().await;
291                metadata.insert(resource.to_string(), (lock_id, token.clone(), expiry));
292            }
293
294            let guard = LockGuard::new(
295                resource.to_string(),
296                token,
297                Utc::now(),
298                ttl,
299                Arc::new(Self {
300                    pool: self.pool.clone(),
301                    metadata: self.metadata.clone(),
302                    _monitor_handle: None,
303                }),
304            );
305
306            Ok(Some(guard))
307        } else {
308            debug!(resource = %resource, "Lock already held");
309            Ok(None)
310        }
311    }
312
313    async fn release_internal(&self, resource: &str, token: &LockToken) -> StateResult<()> {
314        debug!(resource = %resource, token = %token, "Releasing lock");
315
316        // Verify token and get lock ID
317        let lock_id = {
318            let mut metadata = self.metadata.write().await;
319
320            match metadata.get(resource) {
321                Some((lock_id, stored_token, _)) => {
322                    if stored_token != token {
323                        warn!(
324                            resource = %resource,
325                            token = %token,
326                            "Token mismatch during release"
327                        );
328                        return Err(StateError::TransactionFailed {
329                            operation: "release_lock".to_string(),
330                            reason: "Token mismatch".to_string(),
331                        });
332                    }
333
334                    let lock_id = *lock_id;
335                    metadata.remove(resource);
336                    lock_id
337                }
338                None => {
339                    warn!(resource = %resource, "Lock not found in metadata");
340                    return Err(StateError::KeyNotFound {
341                        key: resource.to_string(),
342                    });
343                }
344            }
345        };
346
347        // Release the advisory lock
348        let row = sqlx::query("SELECT pg_advisory_unlock($1) as released")
349            .bind(lock_id)
350            .fetch_one(&self.pool)
351            .await
352            .map_err(|e| StateError::StorageError {
353                backend_type: "PostgreSQL".to_string(),
354                details: format!("Failed to release advisory lock: {}", e),
355            })?;
356
357        let released: bool = row.get("released");
358
359        if !released {
360            warn!(
361                resource = %resource,
362                lock_id = lock_id,
363                "Advisory lock was not held"
364            );
365            return Err(StateError::TransactionFailed {
366                operation: "release_lock".to_string(),
367                reason: "Lock was not held".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 metadata = self.metadata.read().await;
377        let is_locked = metadata.contains_key(resource);
378
379        // Also verify the lock hasn't expired
380        if is_locked {
381            if let Some((_, _, expiry)) = metadata.get(resource) {
382                let now = Utc::now().timestamp_millis();
383                return Ok(*expiry > now);
384            }
385        }
386
387        Ok(is_locked)
388    }
389
390    async fn extend(&self, guard: &mut LockGuard, ttl: Duration) -> StateResult<()> {
391        let new_expiry = Self::calculate_expiry(ttl);
392
393        debug!(
394            resource = %guard.resource(),
395            token = %guard.token(),
396            ttl_ms = ttl.as_millis(),
397            "Extending lock"
398        );
399
400        // Update metadata with new expiry
401        {
402            let mut metadata = self.metadata.write().await;
403
404            match metadata.get_mut(guard.resource()) {
405                Some((_, stored_token, expiry)) => {
406                    if stored_token != guard.token() {
407                        warn!(
408                            resource = %guard.resource(),
409                            "Token mismatch during extension"
410                        );
411                        return Err(StateError::TransactionFailed {
412                            operation: "extend_lock".to_string(),
413                            reason: "Token mismatch".to_string(),
414                        });
415                    }
416
417                    *expiry = new_expiry;
418                }
419                None => {
420                    warn!(resource = %guard.resource(), "Lock not found during extension");
421                    return Err(StateError::KeyNotFound {
422                        key: guard.resource().to_string(),
423                    });
424                }
425            }
426        }
427
428        debug!(resource = %guard.resource(), "Lock extended");
429        Ok(())
430    }
431
432    async fn get_lock_info(&self, resource: &str) -> StateResult<Option<LockToken>> {
433        let metadata = self.metadata.read().await;
434
435        if let Some((_, token, expiry)) = metadata.get(resource) {
436            let now = Utc::now().timestamp_millis();
437            if *expiry > now {
438                return Ok(Some(token.clone()));
439            }
440        }
441
442        Ok(None)
443    }
444}
445
446impl Drop for PostgresDistributedLock {
447    fn drop(&mut self) {
448        // Monitor task will be automatically cancelled when handle is dropped
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    // Helper to create a test PostgreSQL lock (requires PostgreSQL running)
457    async fn create_test_lock() -> Option<PostgresDistributedLock> {
458        // Try to connect to PostgreSQL
459        match PostgresDistributedLock::new("postgresql://localhost/test").await {
460            Ok(lock) => Some(lock),
461            Err(_) => {
462                eprintln!("Skipping PostgreSQL tests - PostgreSQL not available");
463                None
464            }
465        }
466    }
467
468    #[tokio::test]
469    async fn test_acquire_and_release() {
470        let Some(lock) = create_test_lock().await else {
471            return;
472        };
473
474        let resource = format!("test:resource:{}", uuid::Uuid::new_v4());
475
476        // Lock should not exist initially
477        assert!(!lock.is_locked(&resource).await.unwrap());
478
479        // Acquire lock
480        let guard = lock
481            .acquire(&resource, Duration::from_secs(10))
482            .await
483            .unwrap();
484
485        // Lock should exist now
486        assert!(lock.is_locked(&resource).await.unwrap());
487
488        // Release lock
489        lock.release(guard).await.unwrap();
490
491        // Wait a bit for async release
492        tokio::time::sleep(Duration::from_millis(100)).await;
493
494        // Lock should not exist after release
495        assert!(!lock.is_locked(&resource).await.unwrap());
496    }
497
498    #[tokio::test]
499    async fn test_try_acquire() {
500        let Some(lock) = create_test_lock().await else {
501            return;
502        };
503
504        let resource = format!("test:resource:{}", uuid::Uuid::new_v4());
505
506        // First acquisition should succeed
507        let guard1 = lock
508            .try_acquire(&resource, Duration::from_secs(10))
509            .await
510            .unwrap();
511        assert!(guard1.is_some());
512
513        // Second acquisition should fail
514        let guard2 = lock
515            .try_acquire(&resource, Duration::from_secs(10))
516            .await
517            .unwrap();
518        assert!(guard2.is_none());
519
520        // Release first lock
521        drop(guard1);
522
523        // Wait a bit for async drop to complete
524        tokio::time::sleep(Duration::from_millis(100)).await;
525
526        // Third acquisition should succeed
527        let guard3 = lock
528            .try_acquire(&resource, Duration::from_secs(10))
529            .await
530            .unwrap();
531        assert!(guard3.is_some());
532    }
533
534    #[tokio::test]
535    async fn test_lock_extension() {
536        let Some(lock) = create_test_lock().await else {
537            return;
538        };
539
540        let resource = format!("test:resource:{}", uuid::Uuid::new_v4());
541
542        // Acquire lock with short TTL
543        let mut guard = lock
544            .acquire(&resource, Duration::from_secs(2))
545            .await
546            .unwrap();
547
548        // Wait most of the TTL
549        tokio::time::sleep(Duration::from_millis(1500)).await;
550
551        // Extend the lock
552        lock.extend(&mut guard, Duration::from_secs(5))
553            .await
554            .unwrap();
555
556        // Wait past original TTL
557        tokio::time::sleep(Duration::from_millis(1000)).await;
558
559        // Lock should still exist
560        assert!(lock.is_locked(&resource).await.unwrap());
561    }
562
563    #[tokio::test]
564    async fn test_lock_expiration() {
565        let Some(lock) = create_test_lock().await else {
566            return;
567        };
568
569        let resource = format!("test:resource:{}", uuid::Uuid::new_v4());
570
571        // Acquire lock with short TTL
572        let guard = lock
573            .acquire(&resource, Duration::from_secs(1))
574            .await
575            .unwrap();
576
577        assert!(lock.is_locked(&resource).await.unwrap());
578
579        // Don't release, let it expire
580        std::mem::forget(guard);
581
582        // Wait for expiration + monitor cycle
583        tokio::time::sleep(Duration::from_secs(3)).await;
584
585        // Lock should be gone
586        assert!(!lock.is_locked(&resource).await.unwrap());
587    }
588
589    #[tokio::test]
590    async fn test_get_lock_info() {
591        let Some(lock) = create_test_lock().await else {
592            return;
593        };
594
595        let resource = format!("test:resource:{}", uuid::Uuid::new_v4());
596
597        // No lock initially
598        assert!(lock.get_lock_info(&resource).await.unwrap().is_none());
599
600        // Acquire lock
601        let guard = lock
602            .acquire(&resource, Duration::from_secs(10))
603            .await
604            .unwrap();
605
606        // Should return the token
607        let token = lock.get_lock_info(&resource).await.unwrap();
608        assert!(token.is_some());
609        assert_eq!(token.unwrap(), *guard.token());
610    }
611
612    #[test]
613    fn test_resource_to_lock_id() {
614        let id1 = PostgresDistributedLock::resource_to_lock_id("resource1");
615        let id2 = PostgresDistributedLock::resource_to_lock_id("resource2");
616        let id3 = PostgresDistributedLock::resource_to_lock_id("resource1");
617
618        // Same resource should give same ID
619        assert_eq!(id1, id3);
620
621        // Different resources should give different IDs (with high probability)
622        assert_ne!(id1, id2);
623    }
624
625    #[tokio::test]
626    async fn test_concurrent_access() {
627        let Some(lock) = create_test_lock().await else {
628            return;
629        };
630
631        let resource = format!("test:resource:{}", uuid::Uuid::new_v4());
632        let lock = Arc::new(lock);
633
634        let mut handles = vec![];
635
636        // Spawn 5 tasks trying to acquire the same lock
637        for i in 0..5 {
638            let lock = lock.clone();
639            let resource = resource.clone();
640
641            let handle = tokio::spawn(async move {
642                if let Some(guard) = lock
643                    .try_acquire(&resource, Duration::from_secs(1))
644                    .await
645                    .unwrap()
646                {
647                    // Simulate work
648                    tokio::time::sleep(Duration::from_millis(100)).await;
649                    drop(guard);
650                    Some(i)
651                } else {
652                    None
653                }
654            });
655
656            handles.push(handle);
657        }
658
659        // Collect results
660        let mut successful = 0;
661        for handle in handles {
662            if handle.await.unwrap().is_some() {
663                successful += 1;
664            }
665        }
666
667        // At least one should have succeeded
668        assert!(successful >= 1);
669    }
670}