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 self.declared_shared || self.cache.is_shared()
138 }
139
140 /// Take this store's word for it: locks here *are* visible to other
141 /// processes.
142 ///
143 /// For a [`Cache`] implemented outside this crate that has not overridden
144 /// [`Cache::is_shared`] — a Consul, an etcd, somebody's own Redis client.
145 /// Nothing verifies the claim, so declaring it about a per-process store
146 /// disables the one check that would have caught it.
147 ///
148 /// ```
149 /// # use rainier_cache::{LockManager, MemoryCache};
150 /// # use std::sync::Arc;
151 /// let locks = LockManager::new(Arc::new(MemoryCache::new())).declared_shared();
152 /// assert!(locks.is_shared());
153 /// ```
154 #[must_use = "this returns a configured manager rather than configuring in place"]
155 pub fn declared_shared(mut self) -> Self {
156 self.declared_shared = true;
157 self
158 }
159
160 /// The cache underneath.
161 pub fn cache(&self) -> &Arc<dyn Cache> {
162 &self.cache
163 }
164}
165
166impl std::fmt::Debug for LockManager {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 f.debug_struct("LockManager")
169 .field("cache", &self.cache.name())
170 .field("prefix", &self.prefix)
171 .finish()
172 }
173}
174
175/// A named lock, not yet held.
176///
177/// Built by [`LockManager::lock`]. Nothing is taken until
178/// [`acquire`](Self::acquire) or [`run`](Self::run).
179pub struct Lock {
180 cache: Arc<dyn Cache>,
181 key: String,
182 ttl: Duration,
183 wait: Option<Duration>,
184}
185
186impl Lock {
187 /// Wait up to `wait` for the lock instead of giving up immediately.
188 ///
189 /// Polls. That is deliberate: the alternative is a pub/sub channel per lock
190 /// name, which is a lot of moving parts to shave a hundred milliseconds off
191 /// a path that is by definition contended and by definition rare.
192 ///
193 /// Use it for "this must happen, just not twice at once". Leave it off for
194 /// "if somebody else is already doing this, I have nothing to do" — which
195 /// is the scheduler's case, and why `without_overlapping` does not wait.
196 pub fn wait_for(mut self, wait: Duration) -> Self {
197 self.wait = Some(wait);
198 self
199 }
200
201 /// Take the lock, or return `None` if somebody else holds it.
202 pub async fn acquire(&self) -> Result<Option<LockGuard>> {
203 let deadline = self.wait.map(|wait| std::time::Instant::now() + wait);
204
205 loop {
206 let owner = mint_owner();
207
208 if self.cache.add(&self.key, owner.as_bytes(), Some(self.ttl)).await? {
209 return Ok(Some(LockGuard {
210 cache: Arc::clone(&self.cache),
211 key: self.key.clone(),
212 owner,
213 released: false,
214 }));
215 }
216
217 match deadline {
218 Some(deadline) if std::time::Instant::now() + POLL_INTERVAL < deadline => {
219 tokio::time::sleep(POLL_INTERVAL).await;
220 }
221 _ => return Ok(None),
222 }
223 }
224 }
225
226 /// Run `work` while holding the lock, or return `None` without running it.
227 ///
228 /// The lock is released whether `work` succeeds or fails — a task that
229 /// errors must not hold its lock until the TTL expires, or one bad night
230 /// skips the next hour of runs too.
231 ///
232 /// ```
233 /// # use rainier_cache::{LockManager, MemoryCache};
234 /// # use std::sync::Arc;
235 /// # use std::time::Duration;
236 /// # #[tokio::main] async fn main() -> rainier_support::Result<()> {
237 /// # let locks = LockManager::new(Arc::new(MemoryCache::new()));
238 /// let lock = locks.lock("import", Duration::from_secs(60));
239 ///
240 /// match lock.run(async { Ok::<_, rainier_support::Error>(42) }).await? {
241 /// Some(value) => assert_eq!(value, 42),
242 /// None => unreachable!("nobody else holds it"),
243 /// }
244 /// # Ok(()) }
245 /// ```
246 pub async fn run<T, F>(&self, work: F) -> Result<Option<T>>
247 where
248 F: std::future::Future<Output = Result<T>>,
249 {
250 let Some(guard) = self.acquire().await? else {
251 return Ok(None);
252 };
253
254 let outcome = work.await;
255
256 // Released before the result is inspected, so an `Err` does not skip
257 // the release on its way out.
258 guard.release().await?;
259 outcome.map(Some)
260 }
261
262 /// Take the lock over from whoever holds it.
263 ///
264 /// An escape hatch for an operator, not for code. The holder does not find
265 /// out, and if it is still running you now have two.
266 pub async fn force_release(&self) -> Result<bool> {
267 self.cache.forget(&self.key).await
268 }
269
270 /// Whether anybody holds it.
271 ///
272 /// Racy by nature — true the moment you ask and false the moment after —
273 /// so it is for a status page, not for deciding whether to acquire.
274 pub async fn is_held(&self) -> Result<bool> {
275 self.cache.has(&self.key).await
276 }
277
278 /// The cache key, for diagnostics.
279 pub fn key(&self) -> &str {
280 &self.key
281 }
282}
283
284impl std::fmt::Debug for Lock {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 f.debug_struct("Lock").field("key", &self.key).field("ttl", &self.ttl).finish()
287 }
288}
289
290/// A held lock.
291///
292/// # This does not release on drop
293///
294/// Releasing means a round trip to the cache, and `Drop` cannot await. The
295/// options were a blocking call inside `Drop` (which deadlocks a single-threaded
296/// runtime), spawning a task (which needs a runtime handle that may be shutting
297/// down), or being explicit.
298///
299/// Explicit, then — with the TTL as the safety net. A guard that is dropped
300/// without [`release`](Self::release) holds its lock until it expires, which is
301/// late rather than forever. Prefer [`Lock::run`], which cannot forget.
302#[must_use = "a dropped guard holds its lock until the TTL expires; call `release`"]
303pub struct LockGuard {
304 cache: Arc<dyn Cache>,
305 key: String,
306 owner: String,
307 released: bool,
308}
309
310impl LockGuard {
311 /// Release it. `true` if it was still ours to release.
312 ///
313 /// `false` means the TTL expired while we were working and somebody else
314 /// has since taken it — so the work overran, and *two copies have been
315 /// running*. Worth logging where it matters; it is the signal that the TTL
316 /// is too short.
317 pub async fn release(mut self) -> Result<bool> {
318 self.released = true;
319 self.cache.forget_if(&self.key, self.owner.as_bytes()).await
320 }
321
322 /// Push the expiry out to `ttl` from now. `true` if it was still ours.
323 ///
324 /// For work that is still going and does not know how long it has left. A
325 /// `false` here is the same bad news as from `release`, and arrives early
326 /// enough to abandon the work rather than finish it alongside a second
327 /// copy.
328 ///
329 /// Implemented as a re-`put` rather than a bare `EXPIRE`, so it works on
330 /// every driver — including the ones with no separate expiry command.
331 pub async fn extend(&self, ttl: Duration) -> Result<bool> {
332 // Check-then-write, and the gap is real: the lock could expire between
333 // reading and writing, and this would resurrect it under our name while
334 // another holder has it. Narrow, and the alternative is a per-driver
335 // conditional-set primitive for a path that is already a best-effort
336 // heartbeat. `release` is the one that has to be exact, and it is.
337 match self.cache.get(&self.key).await? {
338 Some(current) if current == self.owner.as_bytes() => {
339 self.cache.put(&self.key, self.owner.as_bytes(), Some(ttl)).await?;
340 Ok(true)
341 }
342 _ => Ok(false),
343 }
344 }
345
346 /// Give up the guard **without** releasing the lock, so it stands until the
347 /// TTL expires.
348 ///
349 /// Deliberate abandonment, not a leak — and distinct from dropping the
350 /// guard, which logs a warning on the assumption you forgot.
351 ///
352 /// The case it exists for is a claim that has to outlive the work: the
353 /// scheduler's `on_one_server` takes a lock for the current minute so the
354 /// *other* machines find it taken, and releasing it when the run finishes
355 /// would let a machine whose clock is a second behind claim the same
356 /// minute and run it again.
357 pub fn keep(mut self) {
358 self.released = true;
359 }
360
361 /// Whether we still hold it.
362 pub async fn is_still_held(&self) -> Result<bool> {
363 Ok(self.cache.get(&self.key).await?.as_deref() == Some(self.owner.as_bytes()))
364 }
365
366 /// The random token identifying this holder.
367 pub fn owner(&self) -> &str {
368 &self.owner
369 }
370
371 /// The cache key.
372 pub fn key(&self) -> &str {
373 &self.key
374 }
375}
376
377impl Drop for LockGuard {
378 fn drop(&mut self) {
379 if !self.released {
380 // Not an error — the TTL covers it — but it is nearly always a
381 // missing `release`, and the symptom otherwise is a task that
382 // mysteriously does not run for the next few minutes.
383 tracing::debug!(
384 key = %self.key,
385 "a lock guard was dropped without being released; it will expire on its TTL"
386 );
387 }
388 }
389}
390
391impl std::fmt::Debug for LockGuard {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 f.debug_struct("LockGuard").field("key", &self.key).finish()
394 }
395}
396
397/// A random token identifying one holder.
398///
399/// Two sources mixed, because either alone has a failure mode: a timestamp
400/// collides when two processes acquire in the same nanosecond, and a process id
401/// collides across machines. Together with the address of a fresh allocation
402/// they are enough — this identifies a holder, it does not authenticate one.
403fn mint_owner() -> String {
404 use std::collections::hash_map::RandomState;
405 use std::hash::{BuildHasher, Hasher};
406
407 // `RandomState` is seeded per process from the OS, and hashing a
408 // monotonically-changing value gives a different token per call.
409 let mut hasher = RandomState::new().build_hasher();
410 hasher.write_u128(
411 std::time::SystemTime::now()
412 .duration_since(std::time::UNIX_EPOCH)
413 .map(|d| d.as_nanos())
414 .unwrap_or_default(),
415 );
416 hasher.write_usize(&hasher as *const _ as usize);
417
418 format!("{:016x}{:016x}", hasher.finish(), std::process::id())
419}
420
421/// The error a caller gets when a lock is required rather than optional.
422pub fn contended(name: &str) -> Error {
423 Error::internal(format!("`{name}` is already running elsewhere"))
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::MemoryCache;
430
431 fn locks() -> LockManager {
432 LockManager::new(Arc::new(MemoryCache::new()))
433 }
434
435 #[tokio::test]
436 async fn exactly_one_caller_gets_the_lock() {
437 let locks = locks();
438
439 let first = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap();
440 let second = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap();
441
442 assert!(first.is_some());
443 assert!(second.is_none());
444 }
445
446 #[tokio::test]
447 async fn releasing_lets_the_next_caller_in() {
448 let locks = locks();
449
450 let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
451 assert!(guard.release().await.unwrap());
452
453 assert!(locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_some());
454 }
455
456 #[tokio::test]
457 async fn a_release_after_the_lock_moved_on_does_not_take_it_from_the_new_holder() {
458 // The bug the owner token exists for. A holder that overran has already
459 // lost the lock; its release must be a no-op, not a theft.
460 let locks = locks();
461 let cache = Arc::clone(locks.cache());
462
463 let stale = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
464
465 // Simulate the TTL passing and somebody else taking it.
466 cache.forget("lock:job").await.unwrap();
467 let fresh = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
468
469 // The stale holder releases. It must not remove the fresh holder's lock.
470 assert!(!stale.release().await.unwrap(), "the stale release should be a no-op");
471 assert!(fresh.is_still_held().await.unwrap(), "the new holder should still hold it");
472
473 // And a third caller must still be locked out.
474 assert!(locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_none());
475 }
476
477 #[tokio::test]
478 async fn two_holders_get_different_tokens() {
479 let locks = locks();
480
481 let first = locks.lock("a", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
482 let second = locks.lock("b", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
483
484 assert_ne!(first.owner(), second.owner());
485 }
486
487 #[tokio::test]
488 async fn an_expired_lock_is_available_again() {
489 let locks = locks();
490
491 let _held = locks.lock("job", Duration::from_millis(30)).acquire().await.unwrap().unwrap();
492 assert!(locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_none());
493
494 tokio::time::sleep(Duration::from_millis(60)).await;
495
496 assert!(
497 locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_some(),
498 "the TTL is what stops a dead holder locking a task out forever"
499 );
500 }
501
502 #[tokio::test]
503 async fn run_releases_on_success() {
504 let locks = locks();
505 let lock = locks.lock("job", Duration::from_secs(60));
506
507 assert_eq!(lock.run(async { Ok(7) }).await.unwrap(), Some(7));
508 assert!(!lock.is_held().await.unwrap());
509 }
510
511 #[tokio::test]
512 async fn run_releases_on_failure_too() {
513 // A task that errors must not hold its lock until the TTL: one bad run
514 // would skip every scheduled run until then.
515 let locks = locks();
516 let lock = locks.lock("job", Duration::from_secs(3600));
517
518 let outcome: Result<Option<()>> = lock.run(async { Err(Error::internal("boom")) }).await;
519
520 assert!(outcome.is_err());
521 assert!(!lock.is_held().await.unwrap(), "the lock should have been released");
522 }
523
524 #[tokio::test]
525 async fn run_skips_the_work_when_somebody_else_holds_it() {
526 use std::sync::atomic::{AtomicBool, Ordering};
527
528 let locks = locks();
529 let _held = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
530
531 let ran = AtomicBool::new(false);
532 let outcome = locks
533 .lock("job", Duration::from_secs(60))
534 .run(async {
535 ran.store(true, Ordering::SeqCst);
536 Ok(())
537 })
538 .await
539 .unwrap();
540
541 assert!(outcome.is_none());
542 assert!(!ran.load(Ordering::SeqCst), "the work must not run");
543 }
544
545 #[tokio::test]
546 async fn waiting_gets_the_lock_once_it_is_released() {
547 let locks = locks();
548 let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
549
550 let cache = Arc::clone(locks.cache());
551 tokio::spawn(async move {
552 tokio::time::sleep(Duration::from_millis(50)).await;
553 guard.release().await.unwrap();
554 drop(cache);
555 });
556
557 let waited = locks
558 .lock("job", Duration::from_secs(60))
559 .wait_for(Duration::from_secs(2))
560 .acquire()
561 .await
562 .unwrap();
563
564 assert!(waited.is_some(), "waiting should have outlasted the holder");
565 }
566
567 #[tokio::test]
568 async fn waiting_gives_up_at_the_deadline() {
569 let locks = locks();
570 let _held = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
571
572 let started = std::time::Instant::now();
573 let waited = locks
574 .lock("job", Duration::from_secs(60))
575 .wait_for(Duration::from_millis(250))
576 .acquire()
577 .await
578 .unwrap();
579
580 assert!(waited.is_none());
581 assert!(started.elapsed() < Duration::from_secs(2), "it should not wait forever");
582 }
583
584 #[tokio::test]
585 async fn extending_keeps_a_long_job_from_losing_its_lock() {
586 let locks = locks();
587 let guard = locks.lock("job", Duration::from_millis(50)).acquire().await.unwrap().unwrap();
588
589 assert!(guard.extend(Duration::from_secs(60)).await.unwrap());
590
591 tokio::time::sleep(Duration::from_millis(80)).await;
592 assert!(
593 locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().is_none(),
594 "the original TTL would have expired by now"
595 );
596 }
597
598 #[tokio::test]
599 async fn extending_a_lock_that_moved_on_says_so() {
600 let locks = locks();
601 let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
602
603 locks.lock("job", Duration::from_secs(60)).force_release().await.unwrap();
604 let _other = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
605
606 assert!(!guard.extend(Duration::from_secs(60)).await.unwrap());
607 assert!(!guard.is_still_held().await.unwrap());
608 }
609
610 #[tokio::test]
611 async fn keep_leaves_the_lock_standing_until_the_ttl() {
612 let locks = locks();
613
614 locks.lock("minute", Duration::from_millis(60)).acquire().await.unwrap().unwrap().keep();
615
616 assert!(
617 locks.lock("minute", Duration::from_secs(60)).acquire().await.unwrap().is_none(),
618 "the claim should still stand"
619 );
620
621 tokio::time::sleep(Duration::from_millis(90)).await;
622 assert!(
623 locks.lock("minute", Duration::from_secs(60)).acquire().await.unwrap().is_some(),
624 "and expire on its own"
625 );
626 }
627
628 #[tokio::test]
629 async fn a_memory_cache_is_not_a_shared_lock_and_admits_it() {
630 // The check worth making at boot: `on_one_server` over a per-process
631 // cache is every server believing it is the one.
632 assert!(!locks().is_shared());
633 }
634
635 #[tokio::test]
636 async fn different_names_do_not_contend() {
637 let locks = locks();
638
639 let a = locks.lock("a", Duration::from_secs(60)).acquire().await.unwrap();
640 let b = locks.lock("b", Duration::from_secs(60)).acquire().await.unwrap();
641
642 assert!(a.is_some() && b.is_some());
643 }
644
645 #[tokio::test]
646 async fn the_prefix_keeps_locks_out_of_the_caches_own_namespace() {
647 let locks = locks();
648 let guard = locks.lock("job", Duration::from_secs(60)).acquire().await.unwrap().unwrap();
649
650 assert_eq!(guard.key(), "lock:job");
651 assert!(Arc::clone(locks.cache()).has("lock:job").await.unwrap());
652 assert!(!Arc::clone(locks.cache()).has("job").await.unwrap());
653 }
654}