Skip to main content

ecat_lock/
lib.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2use async_trait::async_trait;
3use std::time::Duration;
4
5/// Distributed lock abstraction.
6///
7/// `acquire` returns an ownership token that must be passed to `release`,
8/// so a lock can only be released by the process that holds it.
9#[async_trait]
10pub trait DistributedLock: Send + Sync {
11    /// Try to acquire the lock for `key` with the given `ttl`.
12    /// Returns `Some(token)` on success, `None` if the lock is held by someone else.
13    async fn acquire(&self, key: &str, ttl: Duration) -> Result<Option<String>, LockError>;
14
15    /// Release the lock for `key`, but only if `token` still matches the holder.
16    async fn release(&self, key: &str, token: &str) -> Result<(), LockError>;
17}
18
19#[derive(Debug, thiserror::Error)]
20pub enum LockError {
21    #[error("lock error: {0}")]
22    Other(String),
23}
24
25#[cfg(test)]
26mod tests {
27    use super::*;
28    use std::collections::HashMap;
29    use std::sync::Arc;
30    use std::time::Instant;
31    use tokio::sync::Mutex;
32
33    /// 内存版参考实现:锁定 trait 契约——token 匹配才能 release、
34    /// TTL 过期后可重新获取、同一 key 同时只允许一个持有者。
35    struct MemoryLock {
36        held: Mutex<HashMap<String, (String, Instant)>>,
37    }
38
39    impl MemoryLock {
40        fn new() -> Arc<Self> {
41            Arc::new(Self {
42                held: Mutex::new(HashMap::new()),
43            })
44        }
45    }
46
47    #[async_trait]
48    impl DistributedLock for MemoryLock {
49        async fn acquire(&self, key: &str, ttl: Duration) -> Result<Option<String>, LockError> {
50            let mut held = self.held.lock().await;
51            if let Some((_, expires)) = held.get(key)
52                && *expires > Instant::now()
53            {
54                return Ok(None);
55            }
56            let token = format!("tok-{key}");
57            held.insert(key.into(), (token.clone(), Instant::now() + ttl));
58            Ok(Some(token))
59        }
60
61        async fn release(&self, key: &str, token: &str) -> Result<(), LockError> {
62            let mut held = self.held.lock().await;
63            match held.get(key) {
64                Some((t, _)) if t == token => {
65                    held.remove(key);
66                    Ok(())
67                }
68                Some(_) => Err(LockError::Other("token mismatch".into())),
69                None => Err(LockError::Other("lock not held".into())),
70            }
71        }
72    }
73
74    #[tokio::test]
75    async fn acquire_free_key_returns_token() {
76        let lock = MemoryLock::new();
77        let token = lock
78            .acquire("job-a", Duration::from_secs(30))
79            .await
80            .unwrap()
81            .expect("free key must be acquirable");
82        assert_eq!(token, "tok-job-a");
83    }
84
85    #[tokio::test]
86    async fn acquire_while_held_returns_none() {
87        let lock = MemoryLock::new();
88        lock.acquire("job-a", Duration::from_secs(30))
89            .await
90            .unwrap();
91        assert_eq!(
92            lock.acquire("job-a", Duration::from_secs(30))
93                .await
94                .unwrap(),
95            None,
96            "lock held by someone else must not be acquirable"
97        );
98    }
99
100    #[tokio::test]
101    async fn release_matching_token_frees_the_lock() {
102        let lock = MemoryLock::new();
103        let token = lock
104            .acquire("job-a", Duration::from_secs(30))
105            .await
106            .unwrap()
107            .unwrap();
108        lock.release("job-a", &token).await.unwrap();
109        // 释放后原持有者可再次获取。
110        lock.acquire("job-a", Duration::from_secs(30))
111            .await
112            .unwrap()
113            .expect("released lock must be acquirable again");
114    }
115
116    #[tokio::test]
117    async fn release_wrong_token_fails() {
118        let lock = MemoryLock::new();
119        lock.acquire("job-a", Duration::from_secs(30))
120            .await
121            .unwrap();
122        let err = lock.release("job-a", "tok-forgery").await.unwrap_err();
123        assert_eq!(err.to_string(), "lock error: token mismatch");
124    }
125
126    #[tokio::test]
127    async fn ttl_expiry_allows_reacquire() {
128        let lock = MemoryLock::new();
129        lock.acquire("job-a", Duration::from_millis(50))
130            .await
131            .unwrap();
132        tokio::time::sleep(Duration::from_millis(150)).await;
133        lock.acquire("job-a", Duration::from_secs(30))
134            .await
135            .unwrap()
136            .expect("expired lock must be acquirable again");
137    }
138
139    #[tokio::test]
140    async fn concurrent_acquire_yields_single_holder() {
141        let lock = MemoryLock::new();
142        let mut tasks = Vec::new();
143        for _ in 0..8 {
144            let lock = lock.clone();
145            tasks.push(tokio::spawn(async move {
146                lock.acquire("shared", Duration::from_secs(30))
147                    .await
148                    .unwrap()
149            }));
150        }
151        let mut winners = 0;
152        for task in tasks {
153            if task.await.unwrap().is_some() {
154                winners += 1;
155            }
156        }
157        assert_eq!(winners, 1, "exactly one acquirer must win the lock");
158    }
159
160    #[test]
161    fn lock_error_displays() {
162        assert_eq!(
163            LockError::Other("boom".into()).to_string(),
164            "lock error: boom"
165        );
166    }
167
168    #[tokio::test]
169    async fn release_unheld_lock_fails() {
170        let lock = MemoryLock::new();
171        let err = lock.release("job-a", "tok-job-a").await.unwrap_err();
172        assert_eq!(err.to_string(), "lock error: lock not held");
173    }
174
175    #[tokio::test]
176    async fn acquire_empty_key_works() {
177        let lock = MemoryLock::new();
178        let token = lock
179            .acquire("", Duration::from_secs(30))
180            .await
181            .unwrap()
182            .expect("empty key must be acquirable");
183        lock.release("", &token).await.unwrap();
184    }
185}