Skip to main content

celers_beat/
lock.rs

1//! Lock management for schedule execution
2//!
3//! Provides `ScheduleLock` and `LockManager` for preventing duplicate
4//! task execution in distributed scenarios.
5
6use crate::config::ScheduleError;
7use chrono::{DateTime, Duration, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Schedule lock for preventing duplicate execution in distributed scenarios
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ScheduleLock {
14    /// Task name this lock is for
15    pub task_name: String,
16    /// Lock owner identifier (e.g., scheduler instance ID)
17    pub owner: String,
18    /// When the lock was acquired
19    pub acquired_at: DateTime<Utc>,
20    /// When the lock expires (for automatic cleanup)
21    pub expires_at: DateTime<Utc>,
22    /// Lock renewal count (for debugging)
23    pub renewal_count: u32,
24}
25
26impl ScheduleLock {
27    /// Create a new schedule lock
28    ///
29    /// # Arguments
30    /// * `task_name` - Name of the task to lock
31    /// * `owner` - Identifier of the lock owner (e.g., scheduler instance ID)
32    /// * `ttl_seconds` - Time-to-live for the lock in seconds
33    pub fn new(task_name: String, owner: String, ttl_seconds: u64) -> Self {
34        let now = Utc::now();
35        Self {
36            task_name,
37            owner,
38            acquired_at: now,
39            expires_at: now + Duration::seconds(ttl_seconds as i64),
40            renewal_count: 0,
41        }
42    }
43
44    /// Check if the lock has expired
45    pub fn is_expired(&self) -> bool {
46        Utc::now() > self.expires_at
47    }
48
49    /// Check if the lock is owned by the given owner
50    pub fn is_owned_by(&self, owner: &str) -> bool {
51        self.owner == owner
52    }
53
54    /// Renew the lock for another TTL period
55    ///
56    /// # Arguments
57    /// * `ttl_seconds` - Time-to-live for the renewed lock
58    ///
59    /// # Returns
60    /// * `Ok(())` if renewed successfully
61    /// * `Err(ScheduleError)` if the lock has already expired
62    pub fn renew(&mut self, ttl_seconds: u64) -> Result<(), ScheduleError> {
63        if self.is_expired() {
64            return Err(ScheduleError::Invalid(
65                "Cannot renew expired lock".to_string(),
66            ));
67        }
68
69        self.expires_at = Utc::now() + Duration::seconds(ttl_seconds as i64);
70        self.renewal_count += 1;
71        Ok(())
72    }
73
74    /// Get remaining time until expiration
75    pub fn ttl(&self) -> Duration {
76        self.expires_at - Utc::now()
77    }
78
79    /// Get age of the lock since acquisition
80    pub fn age(&self) -> Duration {
81        Utc::now() - self.acquired_at
82    }
83}
84
85/// Lock manager for schedule locks
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct LockManager {
88    /// Active locks by task name
89    pub(crate) locks: HashMap<String, ScheduleLock>,
90    /// Default lock TTL in seconds
91    pub(crate) default_ttl: u64,
92}
93
94impl LockManager {
95    /// Create a new lock manager
96    ///
97    /// # Arguments
98    /// * `default_ttl` - Default time-to-live for locks in seconds
99    pub fn new(default_ttl: u64) -> Self {
100        Self {
101            locks: HashMap::new(),
102            default_ttl,
103        }
104    }
105
106    /// Try to acquire a lock for a task
107    ///
108    /// # Arguments
109    /// * `task_name` - Name of the task to lock
110    /// * `owner` - Identifier of the lock owner
111    /// * `ttl` - Optional custom TTL (uses default if None)
112    ///
113    /// # Returns
114    /// * `Ok(true)` if lock acquired successfully
115    /// * `Ok(false)` if lock is already held by another owner
116    /// * `Err` on other errors
117    pub fn try_acquire(
118        &mut self,
119        task_name: &str,
120        owner: &str,
121        ttl: Option<u64>,
122    ) -> Result<bool, ScheduleError> {
123        // Clean up expired locks first
124        self.cleanup_expired();
125
126        // Check if lock exists and is not expired
127        if let Some(existing_lock) = self.locks.get(task_name) {
128            if !existing_lock.is_expired() {
129                // Lock is held by someone else
130                if !existing_lock.is_owned_by(owner) {
131                    return Ok(false);
132                }
133                // Lock is already held by us, consider it acquired
134                return Ok(true);
135            }
136        }
137
138        // Acquire the lock
139        let ttl_seconds = ttl.unwrap_or(self.default_ttl);
140        let lock = ScheduleLock::new(task_name.to_string(), owner.to_string(), ttl_seconds);
141        self.locks.insert(task_name.to_string(), lock);
142        Ok(true)
143    }
144
145    /// Release a lock
146    ///
147    /// # Arguments
148    /// * `task_name` - Name of the task to unlock
149    /// * `owner` - Identifier of the lock owner (must match)
150    ///
151    /// # Returns
152    /// * `Ok(true)` if lock was released
153    /// * `Ok(false)` if lock doesn't exist or is owned by someone else
154    pub fn release(&mut self, task_name: &str, owner: &str) -> Result<bool, ScheduleError> {
155        if let Some(lock) = self.locks.get(task_name) {
156            if lock.is_owned_by(owner) {
157                self.locks.remove(task_name);
158                return Ok(true);
159            }
160        }
161        Ok(false)
162    }
163
164    /// Renew an existing lock
165    ///
166    /// # Arguments
167    /// * `task_name` - Name of the task
168    /// * `owner` - Identifier of the lock owner (must match)
169    /// * `ttl` - Optional custom TTL (uses default if None)
170    ///
171    /// # Returns
172    /// * `Ok(true)` if lock was renewed
173    /// * `Ok(false)` if lock doesn't exist, is owned by someone else, or has expired
174    pub fn renew(
175        &mut self,
176        task_name: &str,
177        owner: &str,
178        ttl: Option<u64>,
179    ) -> Result<bool, ScheduleError> {
180        if let Some(lock) = self.locks.get_mut(task_name) {
181            if lock.is_owned_by(owner) && !lock.is_expired() {
182                let ttl_seconds = ttl.unwrap_or(self.default_ttl);
183                lock.renew(ttl_seconds)?;
184                return Ok(true);
185            }
186        }
187        Ok(false)
188    }
189
190    /// Check if a lock is held
191    ///
192    /// # Arguments
193    /// * `task_name` - Name of the task
194    ///
195    /// # Returns
196    /// * `true` if lock exists and is not expired
197    /// * `false` otherwise
198    pub fn is_locked(&self, task_name: &str) -> bool {
199        if let Some(lock) = self.locks.get(task_name) {
200            !lock.is_expired()
201        } else {
202            false
203        }
204    }
205
206    /// Get information about a lock
207    pub fn get_lock(&self, task_name: &str) -> Option<&ScheduleLock> {
208        self.locks.get(task_name)
209    }
210
211    /// Clean up expired locks
212    pub fn cleanup_expired(&mut self) {
213        self.locks.retain(|_, lock| !lock.is_expired());
214    }
215
216    /// Get all active locks
217    pub fn get_active_locks(&self) -> Vec<&ScheduleLock> {
218        self.locks
219            .values()
220            .filter(|lock| !lock.is_expired())
221            .collect()
222    }
223
224    /// Force release all locks (use with caution)
225    pub fn release_all(&mut self) {
226        self.locks.clear();
227    }
228}
229
230impl Default for LockManager {
231    fn default() -> Self {
232        Self::new(300) // Default 5 minute TTL
233    }
234}
235
236// ---------------------------------------------------------------------------
237// InMemoryLockBackend - implements DistributedLockBackend for single-process use
238// ---------------------------------------------------------------------------
239
240use async_trait::async_trait;
241use celers_core::lock::DistributedLockBackend;
242use std::sync::Arc;
243use std::time::{Duration as StdDuration, Instant};
244use tokio::sync::RwLock;
245
246/// Entry in the in-memory lock store
247#[derive(Debug, Clone)]
248struct LockEntry {
249    /// Owner of this lock
250    owner: String,
251    /// When the lock was acquired
252    acquired_at: Instant,
253    /// Time-to-live for the lock
254    ttl: StdDuration,
255}
256
257impl LockEntry {
258    /// Check if the lock entry has expired
259    fn is_expired(&self) -> bool {
260        self.acquired_at.elapsed() >= self.ttl
261    }
262}
263
264/// In-memory distributed lock backend.
265///
266/// This backend stores locks in a `HashMap` guarded by a `tokio::sync::RwLock`.
267/// It is suitable for single-process deployments or testing. For true distributed
268/// locking across multiple processes/hosts, use `RedisLockBackend` or `DbLockBackend`.
269///
270/// Expired locks are lazily cleaned up on each operation.
271///
272/// # Example
273///
274/// ```rust
275/// use celers_beat::lock::InMemoryLockBackend;
276/// use celers_core::lock::DistributedLockBackend;
277///
278/// # #[tokio::main]
279/// # async fn main() {
280/// let backend = InMemoryLockBackend::new();
281/// let acquired = backend.try_acquire("my_task", "scheduler-1", 300).await.unwrap();
282/// assert!(acquired);
283/// # }
284/// ```
285#[derive(Debug, Clone)]
286pub struct InMemoryLockBackend {
287    locks: Arc<RwLock<HashMap<String, LockEntry>>>,
288}
289
290impl InMemoryLockBackend {
291    /// Create a new in-memory lock backend
292    pub fn new() -> Self {
293        Self {
294            locks: Arc::new(RwLock::new(HashMap::new())),
295        }
296    }
297
298    /// Get the number of currently held (non-expired) locks
299    pub async fn active_lock_count(&self) -> usize {
300        let locks = self.locks.read().await;
301        locks.values().filter(|e| !e.is_expired()).count()
302    }
303}
304
305impl Default for InMemoryLockBackend {
306    fn default() -> Self {
307        Self::new()
308    }
309}
310
311#[async_trait]
312impl DistributedLockBackend for InMemoryLockBackend {
313    async fn try_acquire(
314        &self,
315        key: &str,
316        owner: &str,
317        ttl_secs: u64,
318    ) -> celers_core::error::Result<bool> {
319        let mut locks = self.locks.write().await;
320
321        // Clean up expired entries lazily
322        locks.retain(|_, entry| !entry.is_expired());
323
324        // Check existing lock
325        if let Some(existing) = locks.get(key) {
326            if !existing.is_expired() {
327                // Already owned by same owner => success
328                if existing.owner == owner {
329                    return Ok(true);
330                }
331                // Held by another owner
332                return Ok(false);
333            }
334        }
335
336        // Acquire the lock
337        locks.insert(
338            key.to_string(),
339            LockEntry {
340                owner: owner.to_string(),
341                acquired_at: Instant::now(),
342                ttl: StdDuration::from_secs(ttl_secs),
343            },
344        );
345        Ok(true)
346    }
347
348    async fn release(&self, key: &str, owner: &str) -> celers_core::error::Result<bool> {
349        let mut locks = self.locks.write().await;
350
351        if let Some(entry) = locks.get(key) {
352            if entry.owner == owner {
353                locks.remove(key);
354                return Ok(true);
355            }
356        }
357        Ok(false)
358    }
359
360    async fn renew(
361        &self,
362        key: &str,
363        owner: &str,
364        ttl_secs: u64,
365    ) -> celers_core::error::Result<bool> {
366        let mut locks = self.locks.write().await;
367
368        if let Some(entry) = locks.get_mut(key) {
369            if entry.owner == owner && !entry.is_expired() {
370                entry.acquired_at = Instant::now();
371                entry.ttl = StdDuration::from_secs(ttl_secs);
372                return Ok(true);
373            }
374        }
375        Ok(false)
376    }
377
378    async fn is_locked(&self, key: &str) -> celers_core::error::Result<bool> {
379        let locks = self.locks.read().await;
380        if let Some(entry) = locks.get(key) {
381            Ok(!entry.is_expired())
382        } else {
383            Ok(false)
384        }
385    }
386
387    async fn owner(&self, key: &str) -> celers_core::error::Result<Option<String>> {
388        let locks = self.locks.read().await;
389        if let Some(entry) = locks.get(key) {
390            if !entry.is_expired() {
391                return Ok(Some(entry.owner.clone()));
392            }
393        }
394        Ok(None)
395    }
396
397    async fn release_all(&self, owner: &str) -> celers_core::error::Result<u64> {
398        let mut locks = self.locks.write().await;
399        let before = locks.len();
400        locks.retain(|_, entry| entry.owner != owner);
401        let after = locks.len();
402        Ok((before - after) as u64)
403    }
404}
405
406#[cfg(test)]
407mod in_memory_lock_tests {
408    use super::*;
409
410    #[tokio::test]
411    async fn test_acquire_and_release() {
412        let backend = InMemoryLockBackend::new();
413
414        // Acquire a lock
415        let acquired = backend.try_acquire("task1", "owner1", 300).await;
416        assert!(acquired.is_ok());
417        assert!(acquired.as_ref().is_ok_and(|v| *v));
418
419        // Verify it's locked
420        let locked = backend.is_locked("task1").await;
421        assert!(locked.is_ok_and(|v| v));
422
423        // Verify owner
424        let owner = backend.owner("task1").await;
425        assert!(owner.is_ok());
426        assert_eq!(
427            owner.as_ref().ok().and_then(|o| o.as_deref()),
428            Some("owner1")
429        );
430
431        // Release
432        let released = backend.release("task1", "owner1").await;
433        assert!(released.is_ok_and(|v| v));
434
435        // No longer locked
436        let locked = backend.is_locked("task1").await;
437        assert!(locked.is_ok_and(|v| !v));
438    }
439
440    #[tokio::test]
441    async fn test_acquire_fails_for_different_owner() {
442        let backend = InMemoryLockBackend::new();
443
444        let acquired = backend.try_acquire("task1", "owner1", 300).await;
445        assert!(acquired.is_ok_and(|v| v));
446
447        // Different owner cannot acquire
448        let acquired2 = backend.try_acquire("task1", "owner2", 300).await;
449        assert!(acquired2.is_ok_and(|v| !v));
450    }
451
452    #[tokio::test]
453    async fn test_same_owner_can_reacquire() {
454        let backend = InMemoryLockBackend::new();
455
456        let acquired = backend.try_acquire("task1", "owner1", 300).await;
457        assert!(acquired.is_ok_and(|v| v));
458
459        // Same owner can re-acquire (idempotent)
460        let acquired2 = backend.try_acquire("task1", "owner1", 300).await;
461        assert!(acquired2.is_ok_and(|v| v));
462    }
463
464    #[tokio::test]
465    async fn test_release_wrong_owner_fails() {
466        let backend = InMemoryLockBackend::new();
467
468        let _ = backend.try_acquire("task1", "owner1", 300).await;
469
470        // Wrong owner cannot release
471        let released = backend.release("task1", "owner2").await;
472        assert!(released.is_ok_and(|v| !v));
473
474        // Still locked
475        let locked = backend.is_locked("task1").await;
476        assert!(locked.is_ok_and(|v| v));
477    }
478
479    #[tokio::test]
480    async fn test_renew() {
481        let backend = InMemoryLockBackend::new();
482
483        let _ = backend.try_acquire("task1", "owner1", 300).await;
484
485        // Renew by correct owner
486        let renewed = backend.renew("task1", "owner1", 600).await;
487        assert!(renewed.is_ok_and(|v| v));
488
489        // Renew by wrong owner fails
490        let renewed2 = backend.renew("task1", "owner2", 600).await;
491        assert!(renewed2.is_ok_and(|v| !v));
492    }
493
494    #[tokio::test]
495    async fn test_renew_nonexistent_fails() {
496        let backend = InMemoryLockBackend::new();
497
498        let renewed = backend.renew("nonexistent", "owner1", 600).await;
499        assert!(renewed.is_ok_and(|v| !v));
500    }
501
502    #[tokio::test]
503    async fn test_expiry() {
504        let backend = InMemoryLockBackend::new();
505
506        // Acquire with 0-second TTL (immediately expired)
507        // Note: 0 TTL means Duration::from_secs(0), so elapsed() >= 0 is always true
508        let _ = backend.try_acquire("task1", "owner1", 0).await;
509
510        // Should be expired immediately
511        let locked = backend.is_locked("task1").await;
512        assert!(locked.is_ok_and(|v| !v));
513
514        // Another owner can now acquire
515        let acquired = backend.try_acquire("task1", "owner2", 300).await;
516        assert!(acquired.is_ok_and(|v| v));
517    }
518
519    #[tokio::test]
520    async fn test_release_all() {
521        let backend = InMemoryLockBackend::new();
522
523        let _ = backend.try_acquire("task1", "owner1", 300).await;
524        let _ = backend.try_acquire("task2", "owner1", 300).await;
525        let _ = backend.try_acquire("task3", "owner2", 300).await;
526
527        // Release all for owner1
528        let count = backend.release_all("owner1").await;
529        assert!(count.is_ok());
530        assert_eq!(count.ok(), Some(2));
531
532        // owner2's lock still held
533        let locked = backend.is_locked("task3").await;
534        assert!(locked.is_ok_and(|v| v));
535
536        // owner1's locks gone
537        let locked1 = backend.is_locked("task1").await;
538        assert!(locked1.is_ok_and(|v| !v));
539    }
540
541    #[tokio::test]
542    async fn test_owner_of_nonexistent() {
543        let backend = InMemoryLockBackend::new();
544
545        let owner = backend.owner("nonexistent").await;
546        assert!(owner.is_ok());
547        assert_eq!(owner.ok().flatten(), None);
548    }
549
550    #[tokio::test]
551    async fn test_release_nonexistent() {
552        let backend = InMemoryLockBackend::new();
553
554        let released = backend.release("nonexistent", "owner1").await;
555        assert!(released.is_ok_and(|v| !v));
556    }
557
558    #[tokio::test]
559    async fn test_active_lock_count() {
560        let backend = InMemoryLockBackend::new();
561
562        let _ = backend.try_acquire("task1", "owner1", 300).await;
563        let _ = backend.try_acquire("task2", "owner1", 300).await;
564
565        assert_eq!(backend.active_lock_count().await, 2);
566
567        let _ = backend.release("task1", "owner1").await;
568        assert_eq!(backend.active_lock_count().await, 1);
569    }
570
571    #[tokio::test]
572    async fn test_multiple_keys_independent() {
573        let backend = InMemoryLockBackend::new();
574
575        let _ = backend.try_acquire("task_a", "owner1", 300).await;
576        let _ = backend.try_acquire("task_b", "owner2", 300).await;
577
578        // Both should be locked
579        assert!(backend.is_locked("task_a").await.is_ok_and(|v| v));
580        assert!(backend.is_locked("task_b").await.is_ok_and(|v| v));
581
582        // Different owners
583        let owner_a = backend.owner("task_a").await;
584        let owner_b = backend.owner("task_b").await;
585        assert_eq!(owner_a.ok().flatten().as_deref(), Some("owner1"));
586        assert_eq!(owner_b.ok().flatten().as_deref(), Some("owner2"));
587    }
588}