ash_lock/guard.rs
1//! The RAII [`LockGuard`] returned by a successful acquire.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use tracing::{Instrument, info_span, warn};
7
8use crate::backend::LockBackend;
9use crate::clock::Stamp;
10use crate::error::{Error, Result};
11use crate::watchdog::{LeaseState, WatchdogHandle};
12
13/// Validate that a write carrying `token` is still allowed against a resource
14/// whose last-seen fencing token is `current_token`.
15///
16/// This is the canonical guard external systems should place in front of any
17/// side-effecting write protected by an `ash-lock` lock. A write is only safe
18/// if its token is **at least** the highest token the resource has observed;
19/// any smaller token belongs to a lease that has since been superseded (e.g. a
20/// paused process whose lock expired and was re-acquired elsewhere).
21///
22/// ```
23/// use ash_lock::validate_write;
24/// assert!(validate_write(7, 7)); // current holder
25/// assert!(validate_write(8, 7)); // newer holder writing first
26/// assert!(!validate_write(6, 7)); // stale holder — reject!
27/// ```
28#[must_use]
29pub fn validate_write(token: u64, current_token: u64) -> bool {
30 token >= current_token
31}
32
33/// A held distributed lock.
34///
35/// The lock is released when the guard is dropped (background, best-effort) or
36/// via [`LockGuard::unlock`] (awaited). While held, a supervised watchdog renews
37/// the lease automatically; if renewal fails the guard becomes **poisoned** and
38/// [`LockGuard::fencing_token`] / [`LockGuard::extend`] start returning errors.
39#[must_use = "If dropped, the lock will be released immediately"]
40pub struct LockGuard {
41 backend: Arc<dyn LockBackend>,
42 state: Arc<LeaseState>,
43 watchdog: WatchdogHandle,
44 /// The causal stamp at acquisition time (from `ash-time` when enabled).
45 acquired_at: Stamp,
46 /// Set to false once `unlock()` has consumed the guard, so `Drop` skips the
47 /// background release.
48 active: bool,
49}
50
51impl LockGuard {
52 /// Construct a guard and start its watchdog. Internal: called by the builder.
53 pub(crate) fn new(
54 backend: Arc<dyn LockBackend>,
55 state: Arc<LeaseState>,
56 watchdog: WatchdogHandle,
57 acquired_at: Stamp,
58 ) -> Self {
59 Self {
60 backend,
61 state,
62 watchdog,
63 acquired_at,
64 active: true,
65 }
66 }
67
68 /// The strictly-monotonic fencing token for this lock.
69 ///
70 /// # Errors
71 /// Returns [`Error::LeaseExpired`] if the guard has been poisoned by a
72 /// failed renewal — the token must not be used to authorise writes.
73 pub fn fencing_token(&self) -> Result<u64> {
74 if self.state.is_poisoned() {
75 return Err(Error::LeaseExpired {
76 resource: self.state.resource.clone(),
77 });
78 }
79 Ok(self.state.token)
80 }
81
82 /// The resource key this guard locks.
83 pub fn resource(&self) -> &str {
84 &self.state.resource
85 }
86
87 /// The causal stamp recorded when the lock was acquired.
88 ///
89 /// Backed by the `ash-time` Hybrid Logical Clock when that feature is
90 /// enabled; otherwise a local monotonic counter. Useful for ordering lock
91 /// events in logs/audit trails across nodes.
92 pub fn acquired_at(&self) -> Stamp {
93 self.acquired_at
94 }
95
96 /// Manually extend the lease by `new_ttl` from now. On success the
97 /// watchdog adopts `new_ttl` for all subsequent renewals, so the extension
98 /// is not undone at the next renewal tick.
99 ///
100 /// # Errors
101 /// Fails if the guard is poisoned, or if the backend rejects the renewal
102 /// (stale token / expired lease).
103 pub async fn extend(&self, new_ttl: Duration) -> Result<()> {
104 if self.state.is_poisoned() {
105 return Err(Error::LeaseExpired {
106 resource: self.state.resource.clone(),
107 });
108 }
109 let span = info_span!(
110 "ash_lock.extend",
111 resource_name = %self.state.resource,
112 fencing_token = self.state.token,
113 node_id = self.state.node_id,
114 );
115 async {
116 self.backend
117 .try_renew(&self.state.resource, self.state.token, new_ttl)
118 .await?;
119 self.state.set_ttl(new_ttl);
120 Ok(())
121 }
122 .instrument(span)
123 .await
124 }
125
126 /// Successful watchdog renewals so far. Test observability helper.
127 #[cfg(test)]
128 pub(crate) fn state_renewals(&self) -> u64 {
129 self.state
130 .renewals
131 .load(std::sync::atomic::Ordering::Relaxed)
132 }
133
134 /// Release the lock, awaiting confirmation.
135 pub async fn unlock(mut self) -> Result<()> {
136 self.active = false;
137 self.state.mark_released();
138 self.watchdog.abort();
139 let span = info_span!(
140 "ash_lock.release",
141 resource_name = %self.state.resource,
142 fencing_token = self.state.token,
143 node_id = self.state.node_id,
144 );
145 async {
146 self.backend
147 .release(&self.state.resource, self.state.token)
148 .await
149 }
150 .instrument(span)
151 .await
152 }
153}
154
155impl Drop for LockGuard {
156 fn drop(&mut self) {
157 if !self.active {
158 return; // already released via unlock()
159 }
160 // Signal the watchdog to stop and fire a best-effort background release.
161 self.state.mark_released();
162 self.watchdog.abort();
163
164 let backend = Arc::clone(&self.backend);
165 let resource = self.state.resource.clone();
166 let token = self.state.token;
167 let node_id = self.state.node_id;
168
169 // The spec's Drop "triggers a background release, supervised by Flare".
170 // We spawn the release so Drop stays non-blocking. If no runtime is
171 // active (e.g. guard dropped after the runtime shut down), fall back to
172 // logging — the lease will lapse via TTL regardless.
173 let span = info_span!(
174 "ash_lock.release.drop",
175 resource_name = %resource,
176 fencing_token = token,
177 node_id = node_id,
178 );
179 match tokio::runtime::Handle::try_current() {
180 Ok(handle) => {
181 handle.spawn(
182 async move {
183 let _ = backend.release(&resource, token).await;
184 }
185 .instrument(span),
186 );
187 }
188 Err(_) => {
189 warn!(
190 parent: &span,
191 "no tokio runtime on drop; relying on TTL {:?} to reclaim lock",
192 self.state.ttl()
193 );
194 }
195 }
196 }
197}