Skip to main content

ash_lock/backend/
mod.rs

1//! The [`LockBackend`] abstraction and its implementations.
2//!
3//! A backend owns the authoritative lock state for a set of resource keys. The
4//! client / guard / watchdog layers are backend-agnostic and drive everything
5//! through this trait.
6//!
7//! Implementations provided:
8//! - [`InMemoryBackend`] — a fully working single-process backend (the
9//!   **default**), and the target of the deterministic simulation test.
10//! - `etcd::EtcdBackend` — real distributed locking over an etcd v3 cluster
11//!   (leases + transactions, revision-based fencing tokens). Requires the
12//!   `etcd` feature.
13
14use std::time::{Duration, Instant};
15
16use async_trait::async_trait;
17use dashmap::DashMap;
18
19use crate::error::{Error, Result};
20
21#[cfg(feature = "etcd")]
22pub mod etcd;
23
24/// Authoritative storage and arbitration for distributed locks.
25///
26/// The returned [`Instant`] on `try_acquire` / `try_renew` is the **local**
27/// lease deadline (when this node should consider the lease expired absent a
28/// renewal). It is intentionally not serialized across the wire — remote
29/// backends translate a TTL into a local deadline on receipt.
30#[async_trait]
31pub trait LockBackend: Send + Sync {
32    /// Attempt to acquire `key` for `ttl`. On success returns the fencing token
33    /// (strictly monotonic per key) and the local lease deadline.
34    async fn try_acquire(&self, key: &str, ttl: Duration) -> Result<(u64, Instant)>;
35
36    /// Renew a lease previously acquired with `token`, extending it by `ttl`.
37    /// Returns the new local lease deadline.
38    async fn try_renew(&self, key: &str, token: u64, ttl: Duration) -> Result<Instant>;
39
40    /// Release a lease. A no-op if `token` is already stale (someone else owns
41    /// the key), so releasing an expired guard is harmless.
42    async fn release(&self, key: &str, token: u64) -> Result<()>;
43
44    /// Human-readable backend name, used in tracing spans and errors.
45    fn name(&self) -> &'static str {
46        "unknown"
47    }
48}
49
50// ---------------------------------------------------------------------------
51// InMemoryBackend
52// ---------------------------------------------------------------------------
53
54struct Entry {
55    /// Currently valid fencing token for this key (monotonic).
56    token: u64,
57    /// Whether the key is presently held.
58    held: bool,
59    /// Local deadline after which the lease is considered expired.
60    deadline: Instant,
61}
62
63/// A single-process, in-memory [`LockBackend`].
64///
65/// Fully functional: fencing tokens are strictly monotonic per key, and an
66/// expired lease is reclaimable by the next acquirer. This is the backend the
67/// deterministic simulation test exercises.
68#[derive(Default)]
69pub struct InMemoryBackend {
70    entries: DashMap<String, Entry>,
71}
72
73impl InMemoryBackend {
74    /// Create an empty backend.
75    pub fn new() -> Self {
76        Self {
77            entries: DashMap::new(),
78        }
79    }
80
81    /// Number of tracked keys (held or historically seen). Test helper.
82    pub fn tracked_keys(&self) -> usize {
83        self.entries.len()
84    }
85}
86
87#[async_trait]
88impl LockBackend for InMemoryBackend {
89    async fn try_acquire(&self, key: &str, ttl: Duration) -> Result<(u64, Instant)> {
90        let now = Instant::now();
91        let deadline = now + ttl;
92
93        // DashMap entry API gives us per-key atomicity for the read-modify-write.
94        let mut e = self.entries.entry(key.to_string()).or_insert(Entry {
95            token: 0,
96            held: false,
97            deadline: now,
98        });
99
100        let expired = now >= e.deadline;
101        if e.held && !expired {
102            return Err(Error::Contended {
103                resource: key.to_string(),
104            });
105        }
106
107        // Free or expired: hand out a fresh, strictly larger token.
108        e.token = e
109            .token
110            .checked_add(1)
111            .expect("fencing token overflow (u64)");
112        e.held = true;
113        e.deadline = deadline;
114        Ok((e.token, deadline))
115    }
116
117    async fn try_renew(&self, key: &str, token: u64, ttl: Duration) -> Result<Instant> {
118        let now = Instant::now();
119        let mut e = self.entries.get_mut(key).ok_or_else(|| Error::StaleToken {
120            resource: key.to_string(),
121            token,
122            current: 0,
123        })?;
124
125        if !e.held || now >= e.deadline {
126            return Err(Error::LeaseExpired {
127                resource: key.to_string(),
128            });
129        }
130        if e.token != token {
131            return Err(Error::StaleToken {
132                resource: key.to_string(),
133                token,
134                current: e.token,
135            });
136        }
137
138        let deadline = now + ttl;
139        e.deadline = deadline;
140        Ok(deadline)
141    }
142
143    async fn release(&self, key: &str, token: u64) -> Result<()> {
144        if let Some(mut e) = self.entries.get_mut(key) {
145            // Only the current holder may release; a stale release is a no-op so
146            // that dropping an already-expired guard is safe.
147            if e.held && e.token == token {
148                e.held = false;
149            }
150        }
151        Ok(())
152    }
153
154    fn name(&self) -> &'static str {
155        "in-memory"
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[tokio::test]
164    async fn acquire_is_exclusive_and_monotonic() {
165        let b = InMemoryBackend::new();
166        let (t1, _) = b.try_acquire("k", Duration::from_secs(10)).await.unwrap();
167        assert_eq!(t1, 1);
168        // Second acquire while held must fail.
169        assert!(matches!(
170            b.try_acquire("k", Duration::from_secs(10)).await,
171            Err(Error::Contended { .. })
172        ));
173        b.release("k", t1).await.unwrap();
174        let (t2, _) = b.try_acquire("k", Duration::from_secs(10)).await.unwrap();
175        assert!(t2 > t1, "tokens must strictly increase across acquisitions");
176    }
177
178    #[tokio::test]
179    async fn stale_renew_is_rejected() {
180        let b = InMemoryBackend::new();
181        let (t, _) = b.try_acquire("k", Duration::from_secs(10)).await.unwrap();
182        assert!(
183            b.try_renew("k", t + 999, Duration::from_secs(10))
184                .await
185                .is_err()
186        );
187        assert!(b.try_renew("k", t, Duration::from_secs(10)).await.is_ok());
188    }
189}