1use 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
101const ACQUIRE_RACES: usize = 3;
104
105pub const MAX_SANE_TTL_S: u64 = 3600;
108
109const PROBE_ETAG: &str = "\"mushroomdb-lease-probe-not-an-etag\"";
111
112pub fn new_instance_id() -> String {
114 rand::random::<[u8; 16]>()
115 .iter()
116 .map(|b| format!("{b:02x}"))
117 .collect()
118}
119
120#[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#[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
143pub fn lease_key(prefix: &str) -> String {
145 object_key(prefix, "LEASE")
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct LeaseRecord {
151 pub instance_id: String,
153 pub acquired_at: String,
155 pub renewed_at: String,
157 pub ttl_s: u64,
160}
161
162impl LeaseRecord {
163 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum Exclusion {
190 Conditional,
192 BestEffort,
195}
196
197#[derive(Debug)]
199pub enum LeaseError {
200 ForeignActive { instance_id: String },
202 Lost { instance_id: Option<String> },
205 PreconditionsIgnored { header: &'static str },
209 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 version: UpdateVersion,
249 confirmed: Instant,
252 confirmed_wall: SystemTime,
253 exclusion: Exclusion,
254 lost: bool,
255}
256
257#[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 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 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)) = ¤t {
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 ¤t {
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 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 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 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 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 pub fn instance_id(&self) -> &str {
429 &self.instance_id
430 }
431
432 pub fn ttl_s(&self) -> u64 {
434 self.ttl_s
435 }
436
437 pub fn renew_interval(&self) -> Duration {
439 Duration::from_millis(self.ttl_s * 1000 / 3)
440 }
441
442 pub fn exclusion(&self) -> Exclusion {
444 self.lock().exclusion
445 }
446
447 pub fn is_lost(&self) -> bool {
450 self.lock().lost
451 }
452
453 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 Err(_) => true,
464 };
465 let deadline = h.confirmed + fence;
466 (!h.lost && wall_ok && Instant::now() < deadline).then_some(deadline)
467 }
468
469 pub fn may_write(&self) -> bool {
472 self.write_deadline().is_some()
473 }
474
475 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 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 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 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 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
587pub(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
606async 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
636fn 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
652fn 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 #[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 #[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 #[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); 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}