Skip to main content

rainier_cache/
lock.rs

1//! Atomic locks — [`LockManager`], [`Lock`] and [`LockGuard`].
2//!
3//! Laravel's `Cache::lock('name', 10)`, and the machinery behind
4//! `withoutOverlapping()` and `onOneServer()`.
5//!
6//! ```
7//! # use rainier_cache::{LockManager, MemoryCache};
8//! # use std::sync::Arc;
9//! # use std::time::Duration;
10//! # #[tokio::main] async fn main() -> rainier_support::Result<()> {
11//! let locks = LockManager::new(Arc::new(MemoryCache::new()));
12//!
13//! let outcome = locks
14//!     .lock("reports:nightly", Duration::from_secs(600))
15//!     .run(async {
16//!         // …exactly one caller reaches here.
17//!         Ok::<_, rainier_support::Error>("built")
18//!     })
19//!     .await?;
20//!
21//! assert_eq!(outcome, Some("built"));
22//! # Ok(()) }
23//! ```
24//!
25//! # What makes it a lock
26//!
27//! Three things, and leaving out any one of them produces something that looks
28//! like a lock and is not.
29//!
30//! **An atomic acquire.** [`Cache::add`] is "store only if absent", decided by
31//! the store rather than by the caller. A `has` followed by a `put` lets two
32//! processes both observe the key absent and both write.
33//!
34//! **An owner token.** Every acquire mints a random one and stores it as the
35//! value. Release is [`Cache::forget_if`], not `forget` — see below.
36//!
37//! **A TTL.** The holder can die. A lock with no expiry, taken by a process
38//! that is then `kill -9`ed, is a task that never runs again until somebody
39//! notices and deletes a key by hand.
40//!
41//! ## Why release compares the token
42//!
43//! The bug this avoids is worth spelling out, because it is invisible until it
44//! is a production incident:
45//!
46//! ```text
47//! t=0    A acquires `nightly`, ttl 60s          A holds it
48//! t=0    A begins work
49//! t=61   A is still stalled — GC, a slow query, a suspended VM
50//! t=61   the key expires                        nobody holds it
51//! t=62   B acquires `nightly`                   B holds it
52//! t=63   A finishes and releases
53//!          - with `forget`:     A deletes B's lock. C can now acquire while
54//!                               B is still running. Two copies.
55//!          - with `forget_if`:  A's token no longer matches. Nothing happens.
56//! ```
57//!
58//! `forget_if` cannot make A's overrun safe — B is already running — but it
59//! stops one overrun becoming an unbounded number.
60//!
61//! ## The guarantee, stated honestly
62//!
63//! This is a **lease**, not a mutex. If a holder overruns its TTL, another
64//! process will take the lock and both will run. No lock built on a TTL can
65//! prevent that, including Redlock; the standard advice applies:
66//!
67//! - Set the TTL comfortably longer than the work takes.
68//! - [`extend`](LockGuard::extend) it from long-running work, so the TTL tracks
69//!   progress rather than a guess made at the start.
70//! - Where correctness genuinely cannot tolerate two runs, make the work
71//!   idempotent or fence it with a token the *downstream* system checks. A lock
72//!   is a coordination hint, not a correctness proof.
73//!
74//! And the obvious one: a lock in a [`MemoryCache`](crate::MemoryCache) is
75//! per-process, so `on_one_server` across three servers holding three memory
76//! caches is three servers all believing they are the one.
77//! [`LockManager::is_shared`] answers that question.
78
79use std::sync::Arc;
80use std::time::Duration;
81
82use rainier_support::{Error, Result};
83
84use crate::cache::Cache;
85
86/// How long to wait between attempts when blocking for a lock.
87const POLL_INTERVAL: Duration = Duration::from_millis(100);
88
89/// Mints [`Lock`]s against a cache.
90///
91/// Bind one in the container and resolve it; a lock taken through a different
92/// cache is a different lock.
93pub struct LockManager {
94    cache: Arc<dyn Cache>,
95    prefix: String,
96    declared_shared: bool,
97}
98
99impl LockManager {
100    /// Locks in `cache`, under the `lock:` prefix.
101    pub fn new(cache: Arc<dyn Cache>) -> Self {
102        Self { cache, prefix: "lock:".to_string(), declared_shared: false }
103    }
104
105    /// Locks under a different prefix.
106    ///
107    /// Worth changing when the cache is shared with something else that might
108    /// pick the same names.
109    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
110        self.prefix = prefix.into();
111        self
112    }
113
114    /// A lock called `name`, held for at most `ttl`.
115    pub fn lock(&self, name: impl Into<String>, ttl: Duration) -> Lock {
116        Lock {
117            cache: Arc::clone(&self.cache),
118            key: format!("{}{}", self.prefix, name.into()),
119            ttl,
120            wait: None,
121        }
122    }
123
124    /// Whether locks taken here are visible to other processes.
125    ///
126    /// `false` for an in-process cache, which means `on_one_server` is not
127    /// doing what its name says. Worth asserting at boot rather than
128    /// discovering from a report that ran twice.
129    ///
130    /// ```
131    /// # use rainier_cache::{LockManager, MemoryCache};
132    /// # use std::sync::Arc;
133    /// let locks = LockManager::new(Arc::new(MemoryCache::new()));
134    /// assert!(!locks.is_shared());
135    /// ```
136    pub fn is_shared(&self) -> bool {
137        // Both, and the second is not redundant. A store can be perfectly
138        // shared and still be unable to hold a lock: Cloudflare Workers KV is
139        // visible to every replica on earth and has no compare-and-set, so two
140        // callers both "win" the `add` and both believe they hold it.
141        self.declared_shared || (self.cache.is_shared() && self.cache.supports_atomic_add())
142    }
143
144    /// Take this store's word for it: locks here *are* visible to other
145    /// processes.
146    ///
147    /// For a [`Cache`] implemented outside this crate that has not overridden
148    /// [`Cache::is_shared`] — a Consul, an etcd, somebody's own Redis client.
149    /// Nothing verifies the claim, so declaring it about a per-process store
150    /// disables the one check that would have caught it.
151    ///
152    /// ```
153    /// # use rainier_cache::{LockManager, MemoryCache};
154    /// # use std::sync::Arc;
155    /// let locks = LockManager::new(Arc::new(MemoryCache::new())).declared_shared();
156    /// assert!(locks.is_shared());
157    /// ```
158    #[must_use = "this returns a configured manager rather than configuring in place"]
159    pub fn declared_shared(mut self) -> Self {
160        self.declared_shared = true;
161        self
162    }
163
164    /// The cache underneath.
165    pub fn cache(&self) -> &Arc<dyn Cache> {
166        &self.cache
167    }
168}
169
170impl std::fmt::Debug for LockManager {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("LockManager")
173            .field("cache", &self.cache.name())
174            .field("prefix", &self.prefix)
175            .finish()
176    }
177}
178
179/// A named lock, not yet held.
180///
181/// Built by [`LockManager::lock`]. Nothing is taken until
182/// [`acquire`](Self::acquire) or [`run`](Self::run).
183pub struct Lock {
184    cache: Arc<dyn Cache>,
185    key: String,
186    ttl: Duration,
187    wait: Option<Duration>,
188}
189
190impl Lock {
191    /// Wait up to `wait` for the lock instead of giving up immediately.
192    ///
193    /// Polls. That is deliberate: the alternative is a pub/sub channel per lock
194    /// name, which is a lot of moving parts to shave a hundred milliseconds off
195    /// a path that is by definition contended and by definition rare.
196    ///
197    /// Use it for "this must happen, just not twice at once". Leave it off for
198    /// "if somebody else is already doing this, I have nothing to do" — which
199    /// is the scheduler's case, and why `without_overlapping` does not wait.
200    pub fn wait_for(mut self, wait: Duration) -> Self {
201        self.wait = Some(wait);
202        self
203    }
204
205    /// Take the lock, or return `None` if somebody else holds it.
206    pub async fn acquire(&self) -> Result<Option<LockGuard>> {
207        let deadline = self.wait.map(|wait| std::time::Instant::now() + wait);
208
209        loop {
210            let owner = mint_owner();
211
212            if self.cache.add(&self.key, owner.as_bytes(), Some(self.ttl)).await? {
213                return Ok(Some(LockGuard {
214                    cache: Arc::clone(&self.cache),
215                    key: self.key.clone(),
216                    owner,
217                    released: false,
218                }));
219            }
220
221            match deadline {
222                Some(deadline) if std::time::Instant::now() + POLL_INTERVAL < deadline => {
223                    tokio::time::sleep(POLL_INTERVAL).await;
224                }
225                _ => return Ok(None),
226            }
227        }
228    }
229
230    /// Run `work` while holding the lock, or return `None` without running it.
231    ///
232    /// The lock is released whether `work` succeeds or fails — a task that
233    /// errors must not hold its lock until the TTL expires, or one bad night
234    /// skips the next hour of runs too.
235    ///
236    /// ```
237    /// # use rainier_cache::{LockManager, MemoryCache};
238    /// # use std::sync::Arc;
239    /// # use std::time::Duration;
240    /// # #[tokio::main] async fn main() -> rainier_support::Result<()> {
241    /// # let locks = LockManager::new(Arc::new(MemoryCache::new()));
242    /// let lock = locks.lock("import", Duration::from_secs(60));
243    ///
244    /// match lock.run(async { Ok::<_, rainier_support::Error>(42) }).await? {
245    ///     Some(value) => assert_eq!(value, 42),
246    ///     None => unreachable!("nobody else holds it"),
247    /// }
248    /// # Ok(()) }
249    /// ```
250    pub async fn run<T, F>(&self, work: F) -> Result<Option<T>>
251    where
252        F: std::future::Future<Output = Result<T>>,
253    {
254        let Some(guard) = self.acquire().await? else {
255            return Ok(None);
256        };
257
258        let outcome = work.await;
259
260        // Released before the result is inspected, so an `Err` does not skip
261        // the release on its way out.
262        guard.release().await?;
263        outcome.map(Some)
264    }
265
266    /// Take the lock over from whoever holds it.
267    ///
268    /// An escape hatch for an operator, not for code. The holder does not find
269    /// out, and if it is still running you now have two.
270    pub async fn force_release(&self) -> Result<bool> {
271        self.cache.forget(&self.key).await
272    }
273
274    /// Whether anybody holds it.
275    ///
276    /// Racy by nature — true the moment you ask and false the moment after —
277    /// so it is for a status page, not for deciding whether to acquire.
278    pub async fn is_held(&self) -> Result<bool> {
279        self.cache.has(&self.key).await
280    }
281
282    /// The cache key, for diagnostics.
283    pub fn key(&self) -> &str {
284        &self.key
285    }
286}
287
288impl std::fmt::Debug for Lock {
289    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
290        f.debug_struct("Lock").field("key", &self.key).field("ttl", &self.ttl).finish()
291    }
292}
293
294/// A held lock.
295///
296/// # This does not release on drop
297///
298/// Releasing means a round trip to the cache, and `Drop` cannot await. The
299/// options were a blocking call inside `Drop` (which deadlocks a single-threaded
300/// runtime), spawning a task (which needs a runtime handle that may be shutting
301/// down), or being explicit.
302///
303/// Explicit, then — with the TTL as the safety net. A guard that is dropped
304/// without [`release`](Self::release) holds its lock until it expires, which is
305/// late rather than forever. Prefer [`Lock::run`], which cannot forget.
306#[must_use = "a dropped guard holds its lock until the TTL expires; call `release`"]
307pub struct LockGuard {
308    cache: Arc<dyn Cache>,
309    key: String,
310    owner: String,
311    released: bool,
312}
313
314impl LockGuard {
315    /// Release it. `true` if it was still ours to release.
316    ///
317    /// `false` means the TTL expired while we were working and somebody else
318    /// has since taken it — so the work overran, and *two copies have been
319    /// running*. Worth logging where it matters; it is the signal that the TTL
320    /// is too short.
321    pub async fn release(mut self) -> Result<bool> {
322        self.released = true;
323        self.cache.forget_if(&self.key, self.owner.as_bytes()).await
324    }
325
326    /// Push the expiry out to `ttl` from now. `true` if it was still ours.
327    ///
328    /// For work that is still going and does not know how long it has left. A
329    /// `false` here is the same bad news as from `release`, and arrives early
330    /// enough to abandon the work rather than finish it alongside a second
331    /// copy.
332    ///
333    /// Implemented as a re-`put` rather than a bare `EXPIRE`, so it works on
334    /// every driver — including the ones with no separate expiry command.
335    pub async fn extend(&self, ttl: Duration) -> Result<bool> {
336        // Check-then-write, and the gap is real: the lock could expire between
337        // reading and writing, and this would resurrect it under our name while
338        // another holder has it. Narrow, and the alternative is a per-driver
339        // conditional-set primitive for a path that is already a best-effort
340        // heartbeat. `release` is the one that has to be exact, and it is.
341        match self.cache.get(&self.key).await? {
342            Some(current) if current == self.owner.as_bytes() => {
343                self.cache.put(&self.key, self.owner.as_bytes(), Some(ttl)).await?;
344                Ok(true)
345            }
346            _ => Ok(false),
347        }
348    }
349
350    /// Give up the guard **without** releasing the lock, so it stands until the
351    /// TTL expires.
352    ///
353    /// Deliberate abandonment, not a leak — and distinct from dropping the
354    /// guard, which logs a warning on the assumption you forgot.
355    ///
356    /// The case it exists for is a claim that has to outlive the work: the
357    /// scheduler's `on_one_server` takes a lock for the current minute so the
358    /// *other* machines find it taken, and releasing it when the run finishes
359    /// would let a machine whose clock is a second behind claim the same
360    /// minute and run it again.
361    pub fn keep(mut self) {
362        self.released = true;
363    }
364
365    /// Whether we still hold it.
366    pub async fn is_still_held(&self) -> Result<bool> {
367        Ok(self.cache.get(&self.key).await?.as_deref() == Some(self.owner.as_bytes()))
368    }
369
370    /// The random token identifying this holder.
371    pub fn owner(&self) -> &str {
372        &self.owner
373    }
374
375    /// The cache key.
376    pub fn key(&self) -> &str {
377        &self.key
378    }
379}
380
381impl Drop for LockGuard {
382    fn drop(&mut self) {
383        if !self.released {
384            // Not an error — the TTL covers it — but it is nearly always a
385            // missing `release`, and the symptom otherwise is a task that
386            // mysteriously does not run for the next few minutes.
387            tracing::debug!(
388                key = %self.key,
389                "a lock guard was dropped without being released; it will expire on its TTL"
390            );
391        }
392    }
393}
394
395impl std::fmt::Debug for LockGuard {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        f.debug_struct("LockGuard").field("key", &self.key).finish()
398    }
399}
400
401/// A random token identifying one holder.
402///
403/// Two sources mixed, because either alone has a failure mode: a timestamp
404/// collides when two processes acquire in the same nanosecond, and a process id
405/// collides across machines. Together with the address of a fresh allocation
406/// they are enough — this identifies a holder, it does not authenticate one.
407fn mint_owner() -> String {
408    use std::collections::hash_map::RandomState;
409    use std::hash::{BuildHasher, Hasher};
410
411    // `RandomState` is seeded per process from the OS, and hashing a
412    // monotonically-changing value gives a different token per call.
413    let mut hasher = RandomState::new().build_hasher();
414    hasher.write_u128(
415        std::time::SystemTime::now()
416            .duration_since(std::time::UNIX_EPOCH)
417            .map(|d| d.as_nanos())
418            .unwrap_or_default(),
419    );
420    hasher.write_usize(&hasher as *const _ as usize);
421
422    format!("{:016x}{:016x}", hasher.finish(), std::process::id())
423}
424
425/// The error a caller gets when a lock is required rather than optional.
426pub fn contended(name: &str) -> Error {
427    Error::internal(format!("`{name}` is already running elsewhere"))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::MemoryCache;
434
435    fn locks() -> LockManager {
436        LockManager::new(Arc::new(MemoryCache::new()))
437    }
438
439    #[tokio::test]
440    async fn exactly_one_caller_gets_the_lock() {
441        let locks = locks();
442
443        let first = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap();
444        let second = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap();
445
446        assert!(first.is_some());
447        assert!(second.is_none());
448    }
449
450    #[tokio::test]
451    async fn releasing_lets_the_next_caller_in() {
452        let locks = locks();
453
454        let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
455        assert!(guard.release().await.unwrap());
456
457        assert!(locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_some());
458    }
459
460    #[tokio::test]
461    async fn a_release_after_the_lock_moved_on_does_not_take_it_from_the_new_holder() {
462        // The bug the owner token exists for. A holder that overran has already
463        // lost the lock; its release must be a no-op, not a theft.
464        let locks = locks();
465        let cache = Arc::clone(locks.cache());
466
467        let stale = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
468
469        // Simulate the TTL passing and somebody else taking it.
470        cache.forget("lock:job").await.unwrap();
471        let fresh = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
472
473        // The stale holder releases. It must not remove the fresh holder's lock.
474        assert!(!stale.release().await.unwrap(), "the stale release should be a no-op");
475        assert!(fresh.is_still_held().await.unwrap(), "the new holder should still hold it");
476
477        // And a third caller must still be locked out.
478        assert!(locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_none());
479    }
480
481    #[tokio::test]
482    async fn two_holders_get_different_tokens() {
483        let locks = locks();
484
485        let first = locks.lock("a", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
486        let second = locks.lock("b", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
487
488        assert_ne!(first.owner(), second.owner());
489    }
490
491    #[tokio::test]
492    async fn an_expired_lock_is_available_again() {
493        let locks = locks();
494
495        let _held = locks.lock("job", Duration::from_millis(30)).acquire().await.unwrap().unwrap();
496        assert!(locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_none());
497
498        tokio::time::sleep(Duration::from_millis(60)).await;
499
500        assert!(
501            locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_some(),
502            "the TTL is what stops a dead holder locking a task out forever"
503        );
504    }
505
506    #[tokio::test]
507    async fn run_releases_on_success() {
508        let locks = locks();
509        let lock = locks.lock("job", Duration::from_secs(60));
510
511        assert_eq!(lock.run(async { Ok(7) }).await.unwrap(), Some(7));
512        assert!(!lock.is_held().await.unwrap());
513    }
514
515    #[tokio::test]
516    async fn run_releases_on_failure_too() {
517        // A task that errors must not hold its lock until the TTL: one bad run
518        // would skip every scheduled run until then.
519        let locks = locks();
520        let lock = locks.lock("job", Duration::from_secs(3600));
521
522        let outcome: Result<Option<()>> = lock.run(async { Err(Error::internal("boom")) }).await;
523
524        assert!(outcome.is_err());
525        assert!(!lock.is_held().await.unwrap(), "the lock should have been released");
526    }
527
528    #[tokio::test]
529    async fn run_skips_the_work_when_somebody_else_holds_it() {
530        use std::sync::atomic::{AtomicBool, Ordering};
531
532        let locks = locks();
533        let _held = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
534
535        let ran = AtomicBool::new(false);
536        let outcome = locks
537            .lock("job", Duration::from_secs(60))
538            .run(async {
539                ran.store(true, Ordering::SeqCst);
540                Ok(())
541            })
542            .await
543            .unwrap();
544
545        assert!(outcome.is_none());
546        assert!(!ran.load(Ordering::SeqCst), "the work must not run");
547    }
548
549    #[tokio::test]
550    async fn waiting_gets_the_lock_once_it_is_released() {
551        let locks = locks();
552        let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
553
554        let cache = Arc::clone(locks.cache());
555        tokio::spawn(async move {
556            tokio::time::sleep(Duration::from_millis(50)).await;
557            guard.release().await.unwrap();
558            drop(cache);
559        });
560
561        let waited = locks
562            .lock("job", Duration::from_secs(60))
563            .wait_for(Duration::from_secs(2))
564            .acquire()
565            .await
566            .unwrap();
567
568        assert!(waited.is_some(), "waiting should have outlasted the holder");
569    }
570
571    #[tokio::test]
572    async fn waiting_gives_up_at_the_deadline() {
573        let locks = locks();
574        let _held = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
575
576        let started = std::time::Instant::now();
577        let waited = locks
578            .lock("job", Duration::from_secs(60))
579            .wait_for(Duration::from_millis(250))
580            .acquire()
581            .await
582            .unwrap();
583
584        assert!(waited.is_none());
585        assert!(started.elapsed() < Duration::from_secs(2), "it should not wait forever");
586    }
587
588    #[tokio::test]
589    async fn extending_keeps_a_long_job_from_losing_its_lock() {
590        let locks = locks();
591        let guard = locks.lock("job", Duration::from_millis(50)).acquire().await.unwrap().unwrap();
592
593        assert!(guard.extend(Duration::from_secs(60)).await.unwrap());
594
595        tokio::time::sleep(Duration::from_millis(80)).await;
596        assert!(
597            locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_none(),
598            "the original TTL would have expired by now"
599        );
600    }
601
602    #[tokio::test]
603    async fn extending_a_lock_that_moved_on_says_so() {
604        let locks = locks();
605        let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
606
607        locks.lock("job", Duration::from_secs(60)).force_release().await.unwrap();
608        let _other = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
609
610        assert!(!guard.extend(Duration::from_secs(60)).await.unwrap());
611        assert!(!guard.is_still_held().await.unwrap());
612    }
613
614    #[tokio::test]
615    async fn keep_leaves_the_lock_standing_until_the_ttl() {
616        let locks = locks();
617
618        locks.lock("minute", Duration::from_millis(60)).acquire().await.unwrap().unwrap().keep();
619
620        assert!(
621            locks.lock("minute", Duration::from_secs(60)).acquire().await.unwrap().is_none(),
622            "the claim should still stand"
623        );
624
625        tokio::time::sleep(Duration::from_millis(90)).await;
626        assert!(
627            locks.lock("minute", Duration::from_secs(60)).acquire().await.unwrap().is_some(),
628            "and expire on its own"
629        );
630    }
631
632    #[tokio::test]
633    async fn a_memory_cache_is_not_a_shared_lock_and_admits_it() {
634        // The check worth making at boot: `on_one_server` over a per-process
635        // cache is every server believing it is the one.
636        assert!(!locks().is_shared());
637    }
638
639    #[tokio::test]
640    async fn different_names_do_not_contend() {
641        let locks = locks();
642
643        let a = locks.lock("a", Duration::from_secs(60)).acquire().await.unwrap();
644        let b = locks.lock("b", Duration::from_secs(60)).acquire().await.unwrap();
645
646        assert!(a.is_some() && b.is_some());
647    }
648
649    #[tokio::test]
650    async fn the_prefix_keeps_locks_out_of_the_caches_own_namespace() {
651        let locks = locks();
652        let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
653
654        assert_eq!(guard.key(), "lock:job");
655        assert!(Arc::clone(locks.cache()).has("lock:job").await.unwrap());
656        assert!(!Arc::clone(locks.cache()).has("job").await.unwrap());
657    }
658}