Skip to main content

backup/
lease.rs

1//! The bucket lease: an advisory, remote single-writer lock on a backup
2//! prefix.
3//!
4//! One object, `{prefix}/LEASE`, names the instance that uploads to the
5//! prefix:
6//!
7//! ```json
8//! { "instance_id": "…", "acquired_at": "…", "renewed_at": "…", "ttl_s": 30 }
9//! ```
10//!
11//! The holder renews it every `ttl_s / 3` and deletes it on shutdown. Another
12//! instance that finds a lease renewed less than its `ttl_s` ago (by the
13//! reader's clock) uploads nothing. A lease older than that — its holder died
14//! or lost the network — may be taken over.
15//!
16//! # Advisory, not a distributed mutex
17//!
18//! This catches the common operator error: two machines configured with one
19//! backup bucket. It does not make two writers safe. What it does and does
20//! not promise:
21//!
22//! - **Acquisition and renewal are compare-and-swap where the service allows
23//!   it.** Taking the lease is one conditional PUT — `If-None-Match: *` on an
24//!   empty key, `If-Match: <etag>` over a stale lease — and so is every
25//!   renewal. Two instances that start together get exactly one lease between
26//!   them, and a holder whose lease was taken over learns so at its next
27//!   renewal ([`LeaseError::Lost`]). Where the client cannot send conditional
28//!   PUT (`MUSHROOMDB_BACKUP_LEASE_CONDITIONAL=off`), each step is a read
29//!   followed by an unconditional write ([`Exclusion::BestEffort`], logged at
30//!   WARN): two instances that start together may both pass the check, and
31//!   the later writer wins at the next renewal.
32//! - **A request whose answer was lost is not a lost lease.** An instance
33//!   keeps one id across attempts ([`BucketLease::acquire_as`]). A renewal
34//!   or acquire that timed out on the client but landed on the service
35//!   leaves a lease naming that id; the next attempt recognises it as its
36//!   own instead of waiting out a TTL behind itself.
37//! - **A holder fences itself.** It may start an upload only until two thirds
38//!   of a TTL after its last successful renewal ([`BucketLease::may_write`],
39//!   measured on both the monotonic and the wall clock), and the worker
40//!   cancels any upload still in flight at that moment. Nobody else may take
41//!   the lease before a full TTL has passed, so a holder that cannot renew —
42//!   partitioned from the bucket, or stalled — stops writing a third of a TTL
43//!   before its lease can be taken, and writes nothing after a takeover, the
44//!   partition healed or not.
45//! - **Clock skew** up to that third of a TTL (10 s at the default 30 s) is
46//!   absorbed: a contender whose clock runs ahead of the holder's sees the
47//!   lease expire that much early, still after the holder stopped. A lease
48//!   stamped in the future by a holder whose clock runs ahead counts as fresh;
49//!   such a holder that dies delays a takeover by its skew.
50//! - **The bucket fences too, where it can.** The worker writes every
51//!   immutable object with `If-None-Match: *` and `LATEST` and the manifests
52//!   with `If-Match` against the version it last wrote or read, so on a
53//!   conditional service a stale holder cannot overwrite an object, and its
54//!   late `LATEST` is refused once a successor has written its own.
55//! - **What no lease can stop.** A stale holder's request that the service
56//!   has already received completes, and a process paused between its fence
57//!   check and its request (a VM frozen mid-call) sends it when it wakes. On a
58//!   conditional service such a write can only add an object no manifest of
59//!   the successor names (an orphan) or a manifest `LATEST` does not point
60//!   at; retention deletes nothing a lineage's kept manifests name. Without
61//!   conditional writes, two overlapping writers can overwrite each other's
62//!   objects and corrupt a restore point.
63//!
64//! # Which services give real exclusion
65//!
66//! | Service | Acquire | Renew | Exclusion |
67//! |---|---|---|---|
68//! | AWS S3 | `If-None-Match` / `If-Match` | `If-Match` | conditional |
69//! | Cloudflare R2 | `If-None-Match` / `If-Match` | `If-Match` | conditional |
70//! | MinIO | `If-None-Match` / `If-Match` | `If-Match` | conditional |
71//! | A service that ignores either header | refused at acquire ([`LeaseError::PreconditionsIgnored`]) | — | none: backup does not run |
72//! | `MUSHROOMDB_BACKUP_LEASE_CONDITIONAL=off` | read, then write | read, then write | best-effort |
73//!
74//! A service that accepts a conditional PUT but ignores its precondition is
75//! caught by a probe at every acquire: the lease is written once more with
76//! `If-None-Match: *` and once with `If-Match` on a made-up ETag, and both
77//! must be refused. If either is accepted the acquire fails closed, naming
78//! the cause and the switch that runs best-effort instead. Exclusion is never
79//! weakened silently. The lease reports its kind through
80//! [`BucketLease::exclusion`], and the worker logs it.
81//!
82//! # A lease stamped far in the future
83//!
84//! A record renewed more than its own TTL in the future, or with a TTL over
85//! [`MAX_SANE_TTL_S`], is suspect: a holder with a badly wrong clock, or a
86//! hand-written object. It would otherwise block the prefix for as long as
87//! the clock is wrong. A contender that sees the same suspect record
88//! unchanged for one of its own TTLs ([`LeaseWatch`]) takes it over, and logs
89//! that it did.
90
91use crate::manifest::object_key;
92use object_store::path::Path as ObjectPath;
93use object_store::{ObjectStore, PutMode, PutPayload, PutResult, UpdateVersion};
94use serde::{Deserialize, Serialize};
95use std::sync::{Arc, Mutex};
96use std::time::{Duration, SystemTime};
97use time::format_description::well_known::Rfc3339;
98use time::OffsetDateTime;
99use tokio::time::Instant;
100
101/// How many times [`BucketLease::acquire`] looks again after losing a race
102/// for the key before it reports the winner.
103const ACQUIRE_RACES: usize = 3;
104
105/// The longest TTL a lease record is trusted with (one hour). A record with a
106/// longer one is suspect; see the [module docs](self).
107pub const MAX_SANE_TTL_S: u64 = 3600;
108
109/// An ETag the probe sends with `If-Match`; no service ever issues it.
110const PROBE_ETAG: &str = "\"mushroomdb-lease-probe-not-an-etag\"";
111
112/// A new random instance id: 16 bytes as 32 hex digits.
113pub fn new_instance_id() -> String {
114    rand::random::<[u8; 16]>()
115        .iter()
116        .map(|b| format!("{b:02x}"))
117        .collect()
118}
119
120/// What a contender has seen of a suspect foreign lease across attempts:
121/// which version, and since when, on its own clock.
122#[derive(Debug, Default)]
123pub struct LeaseWatch {
124    seen: Option<(String, Instant)>,
125    #[cfg(test)]
126    pub(crate) probe_control: Option<Arc<ProbeControl>>,
127}
128
129/// Per-attempt request barriers used only by the in-memory lifecycle tests.
130#[cfg(test)]
131#[derive(Debug, Default)]
132pub(crate) struct ProbeControl {
133    pub(crate) block_probe: usize,
134    pub(crate) entered: tokio::sync::Notify,
135    pub(crate) resume: tokio::sync::Notify,
136    pub(crate) shutdown: tokio::sync::Notify,
137    pub(crate) failed: tokio::sync::Notify,
138    pub(crate) acquisitions: std::sync::atomic::AtomicUsize,
139    pub(crate) probes: std::sync::atomic::AtomicUsize,
140    pub(crate) renewals: std::sync::atomic::AtomicUsize,
141}
142
143/// `{prefix}/LEASE`.
144pub fn lease_key(prefix: &str) -> String {
145    object_key(prefix, "LEASE")
146}
147
148/// The `LEASE` object's contents.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct LeaseRecord {
151    /// 32 hex digits, random per process start.
152    pub instance_id: String,
153    /// RFC 3339, the holder's clock, when it took the lease.
154    pub acquired_at: String,
155    /// RFC 3339, the holder's clock, when it last renewed.
156    pub renewed_at: String,
157    /// How long after `renewed_at` the lease stays the holder's. The holder's
158    /// value governs, whatever a contender's own TTL is.
159    pub ttl_s: u64,
160}
161
162impl LeaseRecord {
163    /// Whether the lease is still its holder's at `now`. A `renewed_at` in
164    /// the future (the holder's clock runs ahead) is fresh; one that does not
165    /// parse is not.
166    pub fn is_fresh(&self, now: OffsetDateTime) -> bool {
167        let Ok(renewed) = OffsetDateTime::parse(&self.renewed_at, &Rfc3339) else {
168            return false;
169        };
170        let ttl = time::Duration::seconds(i64::try_from(self.ttl_s).unwrap_or(i64::MAX));
171        now - renewed < ttl
172    }
173
174    /// Whether the record cannot be trusted to expire on time: renewed more
175    /// than its own TTL in the future, or with a TTL over [`MAX_SANE_TTL_S`].
176    pub fn is_suspect(&self, now: OffsetDateTime) -> bool {
177        if self.ttl_s > MAX_SANE_TTL_S {
178            return true;
179        }
180        let Ok(renewed) = OffsetDateTime::parse(&self.renewed_at, &Rfc3339) else {
181            return false;
182        };
183        renewed - now > time::Duration::seconds(self.ttl_s as i64)
184    }
185}
186
187/// How strongly the lease excludes a second writer on this service.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum Exclusion {
190    /// Every acquire and renewal is one compare-and-swap on the service.
191    Conditional,
192    /// The service has no usable conditional PUT: each step reads, then
193    /// writes, and two writers can overlap until the next renewal.
194    BestEffort,
195}
196
197/// Why a lease could not be taken, renewed or released.
198#[derive(Debug)]
199pub enum LeaseError {
200    /// Another instance holds a fresh lease on the prefix.
201    ForeignActive { instance_id: String },
202    /// This lease is no longer ours: the object was replaced or deleted.
203    /// `instance_id` names whoever holds it now, when anyone does.
204    Lost { instance_id: Option<String> },
205    /// The service accepted a conditional PUT whose precondition was false:
206    /// it ignores `If-None-Match` or `If-Match`, so the lease cannot exclude
207    /// anyone. `header` names which.
208    PreconditionsIgnored { header: &'static str },
209    /// The object store refused or failed a request.
210    Store(object_store::Error),
211}
212
213impl std::fmt::Display for LeaseError {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        match self {
216            LeaseError::ForeignActive { instance_id } => write!(
217                f,
218                "the backup bucket's lease is held by another instance ({instance_id})"
219            ),
220            LeaseError::Lost {
221                instance_id: Some(id),
222            } => write!(f, "the bucket lease was taken over by {id}"),
223            LeaseError::Lost { instance_id: None } => write!(f, "the bucket lease was lost"),
224            LeaseError::PreconditionsIgnored { header } => write!(
225                f,
226                "the backup service ignores {header} on PUT, so the bucket lease cannot keep a \
227                 second writer out; backup is not started. Set \
228                 MUSHROOMDB_BACKUP_LEASE_CONDITIONAL=off to run with a best-effort lease"
229            ),
230            LeaseError::Store(e) => {
231                write!(f, "object store: {}", crate::redact(&e.to_string()))
232            }
233        }
234    }
235}
236
237impl std::error::Error for LeaseError {}
238
239impl From<object_store::Error> for LeaseError {
240    fn from(e: object_store::Error) -> Self {
241        LeaseError::Store(e)
242    }
243}
244
245#[derive(Debug)]
246struct Held {
247    /// The version our last write produced, for the next compare-and-swap.
248    version: UpdateVersion,
249    /// Taken just before the last successful write was sent: the lease is
250    /// ours for `ttl` from here, on each clock.
251    confirmed: Instant,
252    confirmed_wall: SystemTime,
253    exclusion: Exclusion,
254    lost: bool,
255}
256
257/// A held lease on `{prefix}/LEASE`. See the [module docs](self).
258#[derive(Debug)]
259pub struct BucketLease {
260    store: Arc<dyn ObjectStore>,
261    key: ObjectPath,
262    instance_id: String,
263    ttl_s: u64,
264    acquired_at: String,
265    held: Mutex<Held>,
266    #[cfg(test)]
267    probe_control: Option<Arc<ProbeControl>>,
268}
269
270impl BucketLease {
271    /// Take the lease on `prefix` for `ttl_s` seconds (0 counts as 1), under
272    /// a new random instance id. See [`acquire_as`](Self::acquire_as).
273    ///
274    /// # Errors
275    ///
276    /// As [`acquire_as`](Self::acquire_as).
277    pub async fn acquire(
278        store: Arc<dyn ObjectStore>,
279        prefix: &str,
280        ttl_s: u64,
281    ) -> Result<BucketLease, LeaseError> {
282        let id = new_instance_id();
283        Self::acquire_as(store, prefix, ttl_s, &id, &mut LeaseWatch::default()).await
284    }
285
286    /// Take the lease on `prefix` for `ttl_s` seconds (0 counts as 1) as
287    /// `instance_id`.
288    ///
289    /// An instance passes the same id and `watch` to every attempt. A lease
290    /// already naming the id — an earlier attempt whose write landed though
291    /// its answer was lost — is taken back at once, and `watch` lets a suspect
292    /// foreign lease be taken over once it has sat unchanged for a TTL.
293    ///
294    /// # Errors
295    ///
296    /// [`LeaseError::ForeignActive`] when another instance holds a fresh
297    /// lease — or won the race for it against this call;
298    /// [`LeaseError::PreconditionsIgnored`] when the service ignores a
299    /// conditional PUT's precondition; [`LeaseError::Store`] when the service
300    /// fails a request.
301    pub async fn acquire_as(
302        store: Arc<dyn ObjectStore>,
303        prefix: &str,
304        ttl_s: u64,
305        instance_id: &str,
306        watch: &mut LeaseWatch,
307    ) -> Result<BucketLease, LeaseError> {
308        let ttl_s = ttl_s.max(1);
309        let key = ObjectPath::from(lease_key(prefix));
310        for _ in 0..ACQUIRE_RACES {
311            let current = read(&store, &key).await?;
312            if let Some((Some(rec), version)) = &current {
313                if rec.instance_id != instance_id {
314                    foreign_blocks(rec, version, ttl_s, watch)?;
315                }
316            }
317            let (confirmed, confirmed_wall) = (Instant::now(), SystemTime::now());
318            let stamp = rfc3339(confirmed_wall);
319            let record = LeaseRecord {
320                instance_id: instance_id.to_string(),
321                acquired_at: stamp.clone(),
322                renewed_at: stamp.clone(),
323                ttl_s,
324            };
325            let mode = match &current {
326                None => PutMode::Create,
327                Some((_, v)) => PutMode::Update(v.clone()),
328            };
329            let (result, exclusion) = match put(&store, &key, &record, mode).await {
330                Ok(r) => (r, Exclusion::Conditional),
331                Err(object_store::Error::AlreadyExists { .. })
332                | Err(object_store::Error::Precondition { .. }) => continue,
333                Err(object_store::Error::NotImplemented) => (
334                    put(&store, &key, &record, PutMode::Overwrite).await?,
335                    Exclusion::BestEffort,
336                ),
337                Err(e) => return Err(e.into()),
338            };
339            let lease = BucketLease {
340                store,
341                key,
342                instance_id: instance_id.to_string(),
343                ttl_s,
344                acquired_at: stamp,
345                held: Mutex::new(Held {
346                    version: version_of(result),
347                    confirmed,
348                    confirmed_wall,
349                    exclusion,
350                    lost: false,
351                }),
352                #[cfg(test)]
353                probe_control: watch.probe_control.clone(),
354            };
355            #[cfg(test)]
356            if let Some(control) = &lease.probe_control {
357                control
358                    .acquisitions
359                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
360            }
361            if exclusion == Exclusion::Conditional {
362                if let Err(e) = lease.probe(&record).await {
363                    if matches!(e, LeaseError::PreconditionsIgnored { .. }) {
364                        // Hand the prefix back: this lease excludes nobody.
365                        let _ =
366                            release_own_record(&lease.store, &lease.key, &lease.instance_id).await;
367                    }
368                    return Err(e);
369                }
370            }
371            *watch = LeaseWatch::default();
372            return Ok(lease);
373        }
374        // Lost every race: whoever won holds it now.
375        let holder = read(&store, &key)
376            .await?
377            .and_then(|(r, _)| r)
378            .map_or_else(|| "unknown".to_string(), |r| r.instance_id);
379        Err(LeaseError::ForeignActive {
380            instance_id: holder,
381        })
382    }
383
384    /// Check that the service refuses both preconditions: a create over the
385    /// existing lease, and an update against an ETag it never issued.
386    /// `NotImplemented` (the client cannot send it) leaves the lease
387    /// best-effort; an accepted write means the service ignores the header.
388    async fn probe(&self, record: &LeaseRecord) -> Result<(), LeaseError> {
389        let fake = UpdateVersion {
390            e_tag: Some(PROBE_ETAG.to_string()),
391            version: None,
392        };
393        for (mode, header) in [
394            (PutMode::Create, "If-None-Match"),
395            (PutMode::Update(fake), "If-Match"),
396        ] {
397            #[cfg(test)]
398            if let Some(control) = &self.probe_control {
399                let index = control.probes.load(std::sync::atomic::Ordering::SeqCst);
400                if index == control.block_probe {
401                    control.entered.notify_one();
402                    control.resume.notified().await;
403                }
404                control
405                    .probes
406                    .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
407            }
408            let answer = put(&self.store, &self.key, record, mode).await;
409            match probe_verdict(answer, header)? {
410                Exclusion::Conditional => {}
411                Exclusion::BestEffort => self.lock().exclusion = Exclusion::BestEffort,
412            }
413        }
414        Ok(())
415    }
416
417    /// Give up the lease without a request: a write elsewhere showed that
418    /// another writer is active.
419    pub(crate) fn mark_lost(&self) {
420        self.lock().lost = true;
421    }
422
423    fn lock(&self) -> std::sync::MutexGuard<'_, Held> {
424        self.held.lock().unwrap_or_else(|e| e.into_inner())
425    }
426
427    /// This process's instance id, as the `LEASE` object names it.
428    pub fn instance_id(&self) -> &str {
429        &self.instance_id
430    }
431
432    /// The TTL this lease was taken with, in seconds.
433    pub fn ttl_s(&self) -> u64 {
434        self.ttl_s
435    }
436
437    /// How often to [`renew`](Self::renew): a third of the TTL.
438    pub fn renew_interval(&self) -> Duration {
439        Duration::from_millis(self.ttl_s * 1000 / 3)
440    }
441
442    /// How strongly this lease excludes a second writer on this service.
443    pub fn exclusion(&self) -> Exclusion {
444        self.lock().exclusion
445    }
446
447    /// Whether the lease was found replaced or deleted. A lost lease never
448    /// comes back; take a new one.
449    pub fn is_lost(&self) -> bool {
450        self.lock().lost
451    }
452
453    /// The moment, on Tokio's clock, after which this holder must not write:
454    /// two thirds of a TTL after the last successful renewal. `None` once the
455    /// lease is lost or that moment has passed on either clock.
456    pub fn write_deadline(&self) -> Option<Instant> {
457        let h = self.lock();
458        let fence = Duration::from_millis(self.ttl_s * 2000 / 3);
459        let wall_ok = match SystemTime::now().duration_since(h.confirmed_wall) {
460            Ok(elapsed) => elapsed < fence,
461            // The wall clock stepped back past the renewal: the monotonic
462            // clock alone decides.
463            Err(_) => true,
464        };
465        let deadline = h.confirmed + fence;
466        (!h.lost && wall_ok && Instant::now() < deadline).then_some(deadline)
467    }
468
469    /// Whether this holder may start a write now. See
470    /// [`write_deadline`](Self::write_deadline).
471    pub fn may_write(&self) -> bool {
472        self.write_deadline().is_some()
473    }
474
475    /// Extend the lease: rewrite it with a new `renewed_at`, provided it is
476    /// still exactly ours.
477    ///
478    /// # Errors
479    ///
480    /// [`LeaseError::Lost`] when the object was replaced or deleted since our
481    /// last write — the lease is then lost for good; [`LeaseError::Store`]
482    /// when the service fails a request (retry; the fence keeps the holder
483    /// safe meanwhile).
484    pub async fn renew(&self) -> Result<(), LeaseError> {
485        let (exclusion, version) = {
486            let h = self.lock();
487            if h.lost {
488                return Err(LeaseError::Lost { instance_id: None });
489            }
490            (h.exclusion, h.version.clone())
491        };
492        let (confirmed, confirmed_wall) = (Instant::now(), SystemTime::now());
493        let record = LeaseRecord {
494            instance_id: self.instance_id.clone(),
495            acquired_at: self.acquired_at.clone(),
496            renewed_at: rfc3339(confirmed_wall),
497            ttl_s: self.ttl_s,
498        };
499        let written = match exclusion {
500            Exclusion::Conditional => {
501                match put(&self.store, &self.key, &record, PutMode::Update(version)).await {
502                    Err(object_store::Error::NotImplemented) => {
503                        self.lock().exclusion = Exclusion::BestEffort;
504                        self.renew_best_effort(&record).await
505                    }
506                    Err(object_store::Error::Precondition { .. })
507                    | Err(object_store::Error::NotFound { .. }) => self.reconcile(&record).await,
508                    other => other.map_err(LeaseError::from),
509                }
510            }
511            Exclusion::BestEffort => self.renew_best_effort(&record).await,
512        }?;
513        let mut h = self.lock();
514        h.version = version_of(written);
515        h.confirmed = confirmed;
516        h.confirmed_wall = confirmed_wall;
517        #[cfg(test)]
518        if let Some(control) = &self.probe_control {
519            control
520                .renewals
521                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
522        }
523        Ok(())
524    }
525
526    /// A compare-and-swap was refused. If the lease still names us, a write
527    /// of ours landed though its answer was lost: adopt the version it left
528    /// and swap again. Otherwise the lease is lost.
529    async fn reconcile(&self, record: &LeaseRecord) -> Result<PutResult, LeaseError> {
530        match read(&self.store, &self.key).await? {
531            Some((Some(r), v)) if r.instance_id == self.instance_id => {
532                match put(&self.store, &self.key, record, PutMode::Update(v)).await {
533                    Ok(w) => Ok(w),
534                    Err(object_store::Error::Precondition { .. })
535                    | Err(object_store::Error::NotFound { .. }) => Err(self.lose().await),
536                    Err(e) => Err(e.into()),
537                }
538            }
539            _ => Err(self.lose().await),
540        }
541    }
542
543    /// Read the lease and rewrite it only if it still names us.
544    async fn renew_best_effort(&self, record: &LeaseRecord) -> Result<PutResult, LeaseError> {
545        match read(&self.store, &self.key).await? {
546            Some((Some(r), _)) if r.instance_id == self.instance_id => {
547                Ok(put(&self.store, &self.key, record, PutMode::Overwrite).await?)
548            }
549            _ => Err(self.lose().await),
550        }
551    }
552
553    /// Mark the lease lost, and say who holds it now.
554    async fn lose(&self) -> LeaseError {
555        self.lock().lost = true;
556        let holder = read(&self.store, &self.key)
557            .await
558            .ok()
559            .flatten()
560            .and_then(|(r, _)| r)
561            .map(|r| r.instance_id)
562            .filter(|id| *id != self.instance_id);
563        LeaseError::Lost {
564            instance_id: holder,
565        }
566    }
567
568    /// Give the lease up: DELETE `{prefix}/LEASE` if it still names us. A
569    /// lost lease, or one another instance now holds, is left alone.
570    ///
571    /// The check and the delete are two requests (object_store 0.11 has no
572    /// conditional DELETE): a takeover landing between them would be deleted,
573    /// and its holder would rewrite it at its next renewal.
574    ///
575    /// # Errors
576    ///
577    /// [`LeaseError::Store`] when the service fails a request; the lease then
578    /// expires by itself after its TTL.
579    pub async fn release(self) -> Result<(), LeaseError> {
580        if self.is_lost() {
581            return Ok(());
582        }
583        release_own_record(&self.store, &self.key, &self.instance_id).await
584    }
585}
586
587/// Release an own record even when acquisition was canceled before its handle
588/// was returned. The caller supplies the whole operation's deadline. As for
589/// `BucketLease::release`, ownership read and DELETE are advisory, not atomic.
590pub(crate) async fn release_own_record(
591    store: &Arc<dyn ObjectStore>,
592    key: &ObjectPath,
593    instance_id: &str,
594) -> Result<(), LeaseError> {
595    match read(store, key).await? {
596        Some((Some(record), _)) if record.instance_id == instance_id => {
597            match store.delete(key).await {
598                Ok(()) | Err(object_store::Error::NotFound { .. }) => Ok(()),
599                Err(error) => Err(error.into()),
600            }
601        }
602        _ => Ok(()),
603    }
604}
605
606/// The lease object, parsed when it parses, with the version to swap it by.
607async fn read(
608    store: &Arc<dyn ObjectStore>,
609    key: &ObjectPath,
610) -> Result<Option<(Option<LeaseRecord>, UpdateVersion)>, LeaseError> {
611    let got = match store.get(key).await {
612        Ok(g) => g,
613        Err(object_store::Error::NotFound { .. }) => return Ok(None),
614        Err(e) => return Err(e.into()),
615    };
616    let version = UpdateVersion {
617        e_tag: got.meta.e_tag.clone(),
618        version: got.meta.version.clone(),
619    };
620    let bytes = got.bytes().await?;
621    Ok(Some((serde_json::from_slice(&bytes).ok(), version)))
622}
623
624async fn put(
625    store: &Arc<dyn ObjectStore>,
626    key: &ObjectPath,
627    record: &LeaseRecord,
628    mode: PutMode,
629) -> Result<PutResult, object_store::Error> {
630    let body = serde_json::to_vec(record).expect("a lease record always serialises");
631    store
632        .put_opts(key, PutPayload::from(body), mode.into())
633        .await
634}
635
636/// What one probe write says about the service: a refusal is real
637/// exclusion; `NotImplemented` (the client cannot send the header) is
638/// best-effort; an accepted write means the service ignores `header`.
639fn probe_verdict(
640    answer: Result<PutResult, object_store::Error>,
641    header: &'static str,
642) -> Result<Exclusion, LeaseError> {
643    match answer {
644        Err(object_store::Error::AlreadyExists { .. })
645        | Err(object_store::Error::Precondition { .. }) => Ok(Exclusion::Conditional),
646        Err(object_store::Error::NotImplemented) => Ok(Exclusion::BestEffort),
647        Ok(_) => Err(LeaseError::PreconditionsIgnored { header }),
648        Err(e) => Err(e.into()),
649    }
650}
651
652/// Whether a foreign record keeps this contender out. A fresh record does,
653/// unless it is suspect and has sat unchanged for one of our TTLs.
654fn foreign_blocks(
655    rec: &LeaseRecord,
656    version: &UpdateVersion,
657    ttl_s: u64,
658    watch: &mut LeaseWatch,
659) -> Result<(), LeaseError> {
660    let now = OffsetDateTime::now_utc();
661    let blocked = || LeaseError::ForeignActive {
662        instance_id: rec.instance_id.clone(),
663    };
664    if !rec.is_fresh(now) {
665        return Ok(());
666    }
667    if !rec.is_suspect(now) {
668        watch.seen = None;
669        return Err(blocked());
670    }
671    let tag = format!(
672        "{}|{}|{}",
673        version.e_tag.as_deref().unwrap_or(""),
674        version.version.as_deref().unwrap_or(""),
675        rec.renewed_at
676    );
677    match &watch.seen {
678        Some((seen, since)) if *seen == tag => {
679            if since.elapsed() >= Duration::from_secs(ttl_s) {
680                tracing::warn!(
681                    holder = %rec.instance_id,
682                    renewed_at = %rec.renewed_at,
683                    ttl_s = rec.ttl_s,
684                    "taking over a suspect bucket lease that has not changed for a TTL"
685                );
686                return Ok(());
687            }
688        }
689        _ => {
690            tracing::warn!(
691                holder = %rec.instance_id,
692                renewed_at = %rec.renewed_at,
693                ttl_s = rec.ttl_s,
694                "the bucket lease is stamped far in the future or has an absurd TTL; \
695                 it is taken over if it stays unchanged for a TTL"
696            );
697            watch.seen = Some((tag, Instant::now()));
698        }
699    }
700    Err(blocked())
701}
702
703fn version_of(r: PutResult) -> UpdateVersion {
704    UpdateVersion {
705        e_tag: r.e_tag,
706        version: r.version,
707    }
708}
709
710fn rfc3339(t: SystemTime) -> String {
711    OffsetDateTime::from(t).format(&Rfc3339).unwrap_or_default()
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    fn record(renewed: OffsetDateTime, ttl_s: u64) -> LeaseRecord {
719        let s = renewed.format(&Rfc3339).unwrap();
720        LeaseRecord {
721            instance_id: "x".into(),
722            acquired_at: s.clone(),
723            renewed_at: s,
724            ttl_s,
725        }
726    }
727
728    #[test]
729    fn freshness_is_strictly_less_than_the_ttl() {
730        let now = OffsetDateTime::now_utc();
731        assert!(record(now - time::Duration::seconds(29), 30).is_fresh(now));
732        assert!(!record(now - time::Duration::seconds(30), 30).is_fresh(now));
733        assert!(record(now + time::Duration::seconds(5), 30).is_fresh(now));
734        let mut garbage = record(now, 30);
735        garbage.renewed_at = "yesterday".into();
736        assert!(!garbage.is_fresh(now));
737        assert!(record(now, u64::MAX).is_fresh(now));
738    }
739
740    /// A service that accepts the probe's write ignores the header: the
741    /// lease fails closed. (A store that ignores preconditions is not
742    /// available to the tests; the decision is checked here.)
743    #[test]
744    fn the_probe_fails_closed_when_a_precondition_is_ignored() {
745        let accepted = || {
746            Ok(PutResult {
747                e_tag: None,
748                version: None,
749            })
750        };
751        for header in ["If-None-Match", "If-Match"] {
752            match probe_verdict(accepted(), header) {
753                Err(e @ LeaseError::PreconditionsIgnored { .. }) => {
754                    let shown = e.to_string();
755                    assert!(shown.contains(header), "{shown}");
756                    assert!(
757                        shown.contains("MUSHROOMDB_BACKUP_LEASE_CONDITIONAL=off"),
758                        "{shown}"
759                    );
760                }
761                other => panic!("{header}: expected PreconditionsIgnored, got {other:?}"),
762            }
763        }
764        let refused = |e| probe_verdict(Err(e), "If-Match").unwrap();
765        assert_eq!(
766            refused(object_store::Error::Precondition {
767                path: "p".into(),
768                source: "no".into()
769            }),
770            Exclusion::Conditional
771        );
772        assert_eq!(
773            refused(object_store::Error::AlreadyExists {
774                path: "p".into(),
775                source: "no".into()
776            }),
777            Exclusion::Conditional
778        );
779        assert_eq!(
780            refused(object_store::Error::NotImplemented),
781            Exclusion::BestEffort
782        );
783        assert!(matches!(
784            probe_verdict(
785                Err(object_store::Error::Generic {
786                    store: "S3",
787                    source: "503".into()
788                }),
789                "If-Match"
790            ),
791            Err(LeaseError::Store(_))
792        ));
793    }
794
795    #[test]
796    fn far_future_stamps_and_absurd_ttls_are_suspect() {
797        let now = OffsetDateTime::now_utc();
798        assert!(!record(now, 30).is_suspect(now));
799        assert!(!record(now + time::Duration::seconds(20), 30).is_suspect(now));
800        assert!(record(now + time::Duration::seconds(31), 30).is_suspect(now));
801        assert!(record(now, MAX_SANE_TTL_S + 1).is_suspect(now));
802        assert!(!record(now, MAX_SANE_TTL_S).is_suspect(now));
803    }
804
805    /// A suspect lease blocks until it has sat unchanged for our TTL.
806    #[tokio::test(start_paused = true)]
807    async fn a_suspect_lease_unchanged_for_a_ttl_is_taken_over() {
808        let store: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
809        let ahead = OffsetDateTime::now_utc() + time::Duration::days(1);
810        let mut rec = record(ahead, 30);
811        rec.instance_id = "clock-a-day-ahead".into();
812        store
813            .put(
814                &ObjectPath::from(lease_key("p")),
815                PutPayload::from(serde_json::to_vec(&rec).unwrap()),
816            )
817            .await
818            .unwrap();
819        let mut watch = LeaseWatch::default();
820        let me = new_instance_id();
821        for _ in 0..2 {
822            assert!(matches!(
823                BucketLease::acquire_as(Arc::clone(&store), "p", 30, &me, &mut watch).await,
824                Err(LeaseError::ForeignActive { .. })
825            ));
826            tokio::time::advance(Duration::from_secs(10)).await;
827        }
828        tokio::time::advance(Duration::from_secs(11)).await;
829        let l = BucketLease::acquire_as(Arc::clone(&store), "p", 30, &me, &mut watch)
830            .await
831            .expect("unchanged for 31 s of a 30 s TTL");
832        assert_eq!(l.instance_id(), me);
833    }
834
835    /// An attempt whose write landed though its answer was lost is
836    /// recognised by the next attempt under the same id.
837    #[tokio::test]
838    async fn a_lease_naming_our_id_is_taken_back_at_once() {
839        let store: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
840        let me = new_instance_id();
841        let mut watch = LeaseWatch::default();
842        let first = BucketLease::acquire_as(Arc::clone(&store), "p", 30, &me, &mut watch)
843            .await
844            .unwrap();
845        drop(first); // its answer "never arrived"
846        let again = BucketLease::acquire_as(Arc::clone(&store), "p", 30, &me, &mut watch)
847            .await
848            .expect("our own fresh lease is ours");
849        again.renew().await.unwrap();
850    }
851
852    #[test]
853    fn the_key_sits_at_the_prefix_root() {
854        assert_eq!(lease_key("a/b"), "a/b/LEASE");
855        assert_eq!(lease_key(""), "LEASE");
856    }
857}