1use std::path::{Path, PathBuf};
72use std::sync::Arc;
73use std::time::Instant;
74
75use anyhow::{bail, Context, Result};
76use object_store::ObjectStore;
77
78use crate::claim::{self, ClaimLost, ClaimOutcome, ClaimRecord};
79use crate::snapshot::BackupTarget;
80use crate::stream::{PreconditionSupport, PreflightStage};
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Tier {
91 Snapshot,
93 Dedup,
95 Stream,
99}
100
101impl Tier {
102 pub fn as_str(&self) -> &'static str {
103 match self {
104 Tier::Snapshot => "snapshot",
105 Tier::Dedup => "dedup",
106 Tier::Stream => "stream",
107 }
108 }
109}
110
111impl std::fmt::Display for Tier {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.write_str(self.as_str())
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum SubjectState {
120 Absent,
127 Populated { bytes: u64 },
130}
131
132impl SubjectState {
133 fn is_absent(&self) -> bool {
134 matches!(self, SubjectState::Absent)
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum Readiness {
141 Hydrate,
143 AlreadyPopulated,
145 TornVolume {
147 populated: Vec<String>,
148 absent: Vec<String>,
149 },
150}
151
152pub fn assess(states: &[(String, SubjectState)]) -> Result<Readiness> {
160 if states.is_empty() {
161 bail!("no subjects to hydrate — a tier that ships bytes must name at least one database");
162 }
163 let (absent, populated): (Vec<_>, Vec<_>) =
164 states.iter().partition(|(_, s)| s.is_absent());
165 match (absent.is_empty(), populated.is_empty()) {
166 (true, false) => Ok(Readiness::AlreadyPopulated),
167 (false, true) => Ok(Readiness::Hydrate),
168 (false, false) => Ok(Readiness::TornVolume {
169 populated: populated.iter().map(|(n, _)| n.clone()).collect(),
170 absent: absent.iter().map(|(n, _)| n.clone()).collect(),
171 }),
172 (true, true) => unreachable!("states was checked non-empty"),
174 }
175}
176
177pub fn inspect_volume(
186 volume_root: &Path,
187 subjects: &[String],
188) -> Result<Vec<(String, SubjectState)>> {
189 subjects
190 .iter()
191 .map(|subject| {
192 let path = subject_path(volume_root, subject)?;
193 let state = match std::fs::metadata(&path) {
194 Ok(m) if m.len() > 0 => SubjectState::Populated { bytes: m.len() },
195 Ok(_) => SubjectState::Absent,
196 Err(e) if e.kind() == std::io::ErrorKind::NotFound => SubjectState::Absent,
197 Err(e) => {
198 return Err(e).with_context(|| format!("stat-ing subject {}", path.display()))
199 }
200 };
201 Ok((subject.clone(), state))
202 })
203 .collect()
204}
205
206pub fn subject_path(volume_root: &Path, subject: &str) -> Result<PathBuf> {
213 if subject.is_empty() {
214 bail!("empty subject path");
215 }
216 let p = Path::new(subject);
217 if p.is_absolute() {
218 bail!("subject {subject:?} is absolute; subjects are relative to the volume root");
219 }
220 if p.components()
221 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir))
222 {
223 bail!("subject {subject:?} contains a \".\" or \"..\" component");
224 }
225 Ok(volume_root.join(p))
226}
227
228#[derive(Debug, Clone, PartialEq)]
230pub struct SubjectRestore {
231 pub subject: String,
232 pub source: String,
235 pub bytes: u64,
237 pub seconds: f64,
242 pub frames_replayed: Option<u64>,
247}
248
249#[derive(Debug, Clone, PartialEq)]
251pub enum HydrateOutcome {
252 Hydrated {
255 epoch: u64,
256 subjects: Vec<SubjectRestore>,
257 seconds: f64,
261 },
262 AlreadyPopulated,
268 NothingInTheStore { epoch: u64, subjects: Vec<String> },
272 Refused(HydrateRefusal),
274}
275
276#[derive(Debug, Clone, PartialEq)]
278pub enum HydrateRefusal {
279 TornVolume {
281 populated: Vec<String>,
282 absent: Vec<String>,
283 },
284 ClaimLost { current: ClaimRecord },
287 FencedMidRestore {
291 lost: ClaimLost,
292 restored: Vec<SubjectRestore>,
293 },
294 SinkNotFenced { stage: PreflightStage },
304}
305
306impl HydrateRefusal {
307 pub fn headline(&self) -> String {
309 match self {
310 HydrateRefusal::TornVolume { populated, absent } => format!(
311 "volume is torn: {} present ({}), {} missing ({}). Restoring only the missing \
312 ones would rebuild them at a different point in time from the ones already \
313 here; decide which copy is authoritative and clear or complete the volume by \
314 hand",
315 populated.len(),
316 populated.join(", "),
317 absent.len(),
318 absent.join(", ")
319 ),
320 HydrateRefusal::ClaimLost { current } => format!(
321 "another node holds this workload's state: epoch {} (owner {}). It is being \
322 placed somewhere else right now",
323 current.epoch, current.owner
324 ),
325 HydrateRefusal::FencedMidRestore { lost, restored } => format!(
326 "{lost} while restoring {} subject(s); the bytes are on disk but this node no \
327 longer owns them — do not start the workload",
328 restored.len()
329 ),
330 HydrateRefusal::SinkNotFenced { stage } => format!(
331 "the object store does not enforce {} — the ownership claim would degrade to \
332 last-write-wins and two nodes could both believe they own this workload's \
333 state. Check that the bucket supports conditional writes (R2 and MinIO do; \
334 some S3-compatible backends do not)",
335 stage.as_str()
336 ),
337 }
338 }
339}
340
341pub struct HydrateRequest<'a> {
343 pub store: Arc<dyn ObjectStore>,
345 pub store_prefix: &'a str,
348 pub volume_root: &'a Path,
351 pub subjects: &'a [String],
353 pub tier: Tier,
354 pub owner: &'a str,
356}
357
358impl HydrateRequest<'_> {
359 fn workload_target(&self) -> BackupTarget {
360 BackupTarget {
361 store: self.store.clone(),
362 prefix: self.store_prefix.to_string(),
363 }
364 }
365
366 fn subject_target(&self, subject: &str) -> BackupTarget {
367 let prefix = self.store_prefix.trim_matches('/');
368 BackupTarget {
369 store: self.store.clone(),
370 prefix: if prefix.is_empty() {
371 subject.to_string()
372 } else {
373 format!("{prefix}/{subject}")
374 },
375 }
376 }
377}
378
379pub async fn hydrate(req: HydrateRequest<'_>) -> Result<HydrateOutcome> {
391 let states = inspect_volume(req.volume_root, req.subjects)?;
392 match assess(&states)? {
393 Readiness::AlreadyPopulated => return Ok(HydrateOutcome::AlreadyPopulated),
394 Readiness::TornVolume { populated, absent } => {
395 return Ok(HydrateOutcome::Refused(HydrateRefusal::TornVolume {
396 populated,
397 absent,
398 }))
399 }
400 Readiness::Hydrate => {}
401 }
402
403 let started = Instant::now();
404 let workload_target = req.workload_target();
405
406 if let PreconditionSupport::Degraded { stage } =
410 crate::stream::probe_conditional_puts(&workload_target).await?
411 {
412 return Ok(HydrateOutcome::Refused(HydrateRefusal::SinkNotFenced {
413 stage,
414 }));
415 }
416
417 let epoch = match claim::acquire(&workload_target, req.owner).await? {
418 ClaimOutcome::Granted { claim, .. } => claim.epoch,
419 ClaimOutcome::Lost { current } => {
420 return Ok(HydrateOutcome::Refused(HydrateRefusal::ClaimLost {
421 current,
422 }))
423 }
424 };
425
426 let mut restored = Vec::new();
427 let mut missing = Vec::new();
428 for subject in req.subjects {
429 let dest = subject_path(req.volume_root, subject)?;
430 if let Some(parent) = dest.parent() {
434 std::fs::create_dir_all(parent)
435 .with_context(|| format!("creating {} for subject {subject}", parent.display()))?;
436 }
437 let dest_str = dest
438 .to_str()
439 .with_context(|| format!("subject path {} is not UTF-8", dest.display()))?;
440
441 let target = req.subject_target(subject);
442 let subject_started = Instant::now();
443 let Some((source, frames_replayed)) =
444 restore_subject(&target, req.tier, dest_str).await?
445 else {
446 missing.push(subject.clone());
447 continue;
448 };
449 let bytes = std::fs::metadata(&dest)
450 .with_context(|| format!("stat-ing restored subject {}", dest.display()))?
451 .len();
452 restored.push(SubjectRestore {
453 subject: subject.clone(),
454 source,
455 bytes,
456 seconds: subject_started.elapsed().as_secs_f64(),
457 frames_replayed,
458 });
459 }
460
461 if !missing.is_empty() && !restored.is_empty() {
466 return Ok(HydrateOutcome::Refused(HydrateRefusal::TornVolume {
467 populated: restored.into_iter().map(|r| r.subject).collect(),
468 absent: missing,
469 }));
470 }
471
472 if let Err(lost) = claim::assert_holds(&workload_target, epoch).await? {
474 return Ok(HydrateOutcome::Refused(HydrateRefusal::FencedMidRestore {
475 lost,
476 restored,
477 }));
478 }
479
480 if restored.is_empty() {
481 return Ok(HydrateOutcome::NothingInTheStore {
482 epoch,
483 subjects: missing,
484 });
485 }
486 Ok(HydrateOutcome::Hydrated {
487 epoch,
488 subjects: restored,
489 seconds: started.elapsed().as_secs_f64(),
490 })
491}
492
493async fn restore_subject(
500 target: &BackupTarget,
501 tier: Tier,
502 dest: &str,
503) -> Result<Option<(String, Option<u64>)>> {
504 match tier {
505 Tier::Snapshot => match crate::snapshot::restore_latest(target, dest).await {
506 Ok(key) => Ok(Some((key, None))),
507 Err(e) if is_absent_source(&e) => Ok(None),
508 Err(e) => Err(e),
509 },
510 Tier::Dedup => match crate::dedup::load_latest_manifest(target).await? {
511 None => Ok(None),
512 Some((key, manifest)) => {
513 crate::dedup::restore_from_manifest(target, &manifest, dest).await?;
514 Ok(Some((key, None)))
515 }
516 },
517 Tier::Stream => {
518 let manifests = crate::stream::list_and_parse_generation_manifests(target).await?;
523 if manifests.is_empty() {
524 return match crate::snapshot::restore_latest(target, dest).await {
525 Ok(key) => Ok(Some((key, Some(0)))),
526 Err(e) if is_absent_source(&e) => Ok(None),
527 Err(e) => Err(e),
528 };
529 }
530 let outcome =
531 crate::stream::restore_stream_from_manifests(target, dest, &manifests).await?;
532 Ok(Some((
533 outcome.base_snapshot_key.clone(),
534 Some(outcome.frames_replayed),
535 )))
536 }
537 }
538}
539
540fn is_absent_source(e: &anyhow::Error) -> bool {
552 let text = format!("{e:#}");
553 text.contains("no snapshots found under") || text.contains("no snapshot manifest found")
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559 use object_store::memory::InMemory;
560 use object_store::ObjectStoreExt;
561
562 fn s(names: &[(&str, SubjectState)]) -> Vec<(String, SubjectState)> {
563 names.iter().map(|(n, st)| (n.to_string(), *st)).collect()
564 }
565
566 const PRESENT: SubjectState = SubjectState::Populated { bytes: 4096 };
567
568 #[test]
571 fn an_empty_volume_hydrates_and_a_full_one_does_not() {
572 assert_eq!(
573 assess(&s(&[("a.db", SubjectState::Absent), ("b.db", SubjectState::Absent)])).unwrap(),
574 Readiness::Hydrate
575 );
576 assert_eq!(
577 assess(&s(&[("a.db", PRESENT), ("b.db", PRESENT)])).unwrap(),
578 Readiness::AlreadyPopulated
579 );
580 }
581
582 #[test]
586 fn a_partly_populated_volume_is_refused_rather_than_topped_up() {
587 let r = assess(&s(&[
588 ("accounts.db", PRESENT),
589 ("passkeys.db", SubjectState::Absent),
590 ("sessions.db", PRESENT),
591 ]))
592 .unwrap();
593 assert_eq!(
594 r,
595 Readiness::TornVolume {
596 populated: vec!["accounts.db".into(), "sessions.db".into()],
597 absent: vec!["passkeys.db".into()],
598 }
599 );
600 }
601
602 #[test]
605 fn a_zero_length_file_counts_as_absent() {
606 let dir = tempdir("zero");
607 std::fs::write(dir.join("a.db"), b"").unwrap();
608 let states = inspect_volume(&dir, &["a.db".to_string()]).unwrap();
609 assert_eq!(states[0].1, SubjectState::Absent);
610 assert_eq!(assess(&states).unwrap(), Readiness::Hydrate);
611 }
612
613 #[test]
614 fn inspect_volume_reports_sizes_for_what_is_there() {
615 let dir = tempdir("sizes");
616 std::fs::write(dir.join("a.db"), b"0123456789").unwrap();
617 let states = inspect_volume(&dir, &["a.db".into(), "b.db".into()]).unwrap();
618 assert_eq!(states[0].1, SubjectState::Populated { bytes: 10 });
619 assert_eq!(states[1].1, SubjectState::Absent);
620 }
621
622 #[test]
627 fn a_traversing_subject_is_refused_at_the_path_join_too() {
628 let dir = tempdir("traverse");
629 for bad in ["/etc/passwd", "../../etc/passwd", "./a.db"] {
630 let err = inspect_volume(&dir, &[bad.to_string()]).unwrap_err();
631 assert!(
632 format!("{err:#}").contains(bad),
633 "refusal should name the subject: {err:#}"
634 );
635 }
636 }
637
638 #[test]
639 fn no_subjects_at_all_is_an_error_not_a_silent_no_op() {
640 let err = assess(&[]).unwrap_err();
641 assert!(format!("{err:#}").contains("at least one database"), "{err:#}");
642 }
643
644 fn tempdir(tag: &str) -> PathBuf {
647 let p = std::env::temp_dir().join(format!(
648 "turso-hydrate-test-{tag}-{}-{:?}",
649 std::process::id(),
650 std::time::SystemTime::now()
651 .duration_since(std::time::UNIX_EPOCH)
652 .unwrap()
653 .as_nanos()
654 ));
655 std::fs::create_dir_all(&p).unwrap();
656 p
657 }
658
659 fn db_image(tag: &[u8]) -> Vec<u8> {
662 let mut v = b"SQLite format 3\0".to_vec();
663 v.extend_from_slice(tag);
664 v
665 }
666
667 async fn seed_snapshot(store: &Arc<dyn ObjectStore>, prefix: &str, tag: &[u8]) -> String {
670 let target = BackupTarget {
671 store: store.clone(),
672 prefix: prefix.to_string(),
673 };
674 crate::snapshot::upload_base_snapshot(&target, &db_image(tag))
675 .await
676 .unwrap()
677 }
678
679 fn request<'a>(
680 store: &Arc<dyn ObjectStore>,
681 volume: &'a Path,
682 subjects: &'a [String],
683 owner: &'a str,
684 ) -> HydrateRequest<'a> {
685 HydrateRequest {
686 store: store.clone(),
687 store_prefix: "wl/acct",
688 volume_root: volume,
689 subjects,
690 tier: Tier::Snapshot,
691 owner,
692 }
693 }
694
695 #[tokio::test]
696 async fn an_empty_volume_is_filled_from_the_store_under_a_fresh_epoch() {
697 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
698 seed_snapshot(&store, "wl/acct/accounts.db", b"ACCOUNTS").await;
699 seed_snapshot(&store, "wl/acct/sessions.db", b"SESSIONS").await;
700 let dir = tempdir("fill");
701 let subjects = vec!["accounts.db".to_string(), "sessions.db".to_string()];
702
703 let out = hydrate(request(&store, &dir, &subjects, "us-west-003"))
704 .await
705 .unwrap();
706 let HydrateOutcome::Hydrated { epoch, subjects: r, .. } = out else {
707 panic!("expected Hydrated, got {out:?}");
708 };
709 assert_eq!(epoch, claim::FIRST_EPOCH);
710 assert_eq!(r.len(), 2);
711 assert_eq!(
712 std::fs::read(dir.join("accounts.db")).unwrap(),
713 db_image(b"ACCOUNTS")
714 );
715 assert_eq!(
716 std::fs::read(dir.join("sessions.db")).unwrap(),
717 db_image(b"SESSIONS")
718 );
719 assert_eq!(r[0].bytes, db_image(b"ACCOUNTS").len() as u64);
721
722 let held = claim::read_claim(&BackupTarget {
724 store: store.clone(),
725 prefix: "wl/acct".into(),
726 })
727 .await
728 .unwrap()
729 .unwrap();
730 assert_eq!(held.owner, "us-west-003");
731 }
732
733 #[tokio::test]
738 async fn an_ordinary_restart_neither_restores_nor_takes_a_claim() {
739 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
740 seed_snapshot(&store, "wl/acct/accounts.db", b"OLD-BACKUP").await;
741 let dir = tempdir("restart");
742 std::fs::write(dir.join("accounts.db"), b"LIVE-DATA-WRITTEN-SINCE").unwrap();
743 let subjects = vec!["accounts.db".to_string()];
744
745 let out = hydrate(request(&store, &dir, &subjects, "us-east-001"))
746 .await
747 .unwrap();
748 assert_eq!(out, HydrateOutcome::AlreadyPopulated);
749 assert_eq!(
750 std::fs::read(dir.join("accounts.db")).unwrap(),
751 b"LIVE-DATA-WRITTEN-SINCE"
752 );
753 assert_eq!(
754 claim::read_claim(&BackupTarget {
755 store: store.clone(),
756 prefix: "wl/acct".into()
757 })
758 .await
759 .unwrap(),
760 None,
761 "a restart must not mint an epoch"
762 );
763 }
764
765 #[tokio::test]
766 async fn a_torn_volume_is_refused_before_the_claim_is_touched() {
767 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
768 seed_snapshot(&store, "wl/acct/accounts.db", b"A").await;
769 seed_snapshot(&store, "wl/acct/sessions.db", b"S").await;
770 let dir = tempdir("torn");
771 std::fs::write(dir.join("accounts.db"), b"LIVE").unwrap();
772 let subjects = vec!["accounts.db".to_string(), "sessions.db".to_string()];
773
774 let out = hydrate(request(&store, &dir, &subjects, "n1")).await.unwrap();
775 let HydrateOutcome::Refused(refusal) = out else {
776 panic!("expected a refusal, got {out:?}");
777 };
778 assert!(refusal.headline().contains("sessions.db"), "{}", refusal.headline());
779 assert!(
780 !dir.join("sessions.db").exists(),
781 "a refused hydrate must not have written anything"
782 );
783 }
784
785 #[tokio::test]
791 async fn losing_the_claim_race_refuses_instead_of_hydrating() {
792 let store = racing_store(RivalOn::ClaimPut, 9, "us-east-001");
793 seed_snapshot(&store, "wl/acct/accounts.db", b"A").await;
794 let dir = tempdir("raced");
795 let subjects = vec!["accounts.db".to_string()];
796
797 let out = hydrate(request(&store, &dir, &subjects, "us-west-003"))
798 .await
799 .unwrap();
800 let HydrateOutcome::Refused(HydrateRefusal::ClaimLost { current }) = out else {
801 panic!("expected ClaimLost, got {out:?}");
802 };
803 assert_eq!(current.owner, "us-east-001");
804 assert_eq!(current.epoch, 9);
805 assert!(
806 !dir.join("accounts.db").exists(),
807 "a node that lost the claim must not have read a byte of state"
808 );
809 }
810
811 #[tokio::test]
815 async fn a_sequential_takeover_still_hydrates() {
816 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
817 seed_snapshot(&store, "wl/acct/accounts.db", b"A").await;
818 let dir = tempdir("failover");
819 let subjects = vec!["accounts.db".to_string()];
820
821 claim::acquire(
822 &BackupTarget {
823 store: store.clone(),
824 prefix: "wl/acct".into(),
825 },
826 "us-east-001",
827 )
828 .await
829 .unwrap();
830 let out = hydrate(request(&store, &dir, &subjects, "us-west-003"))
831 .await
832 .unwrap();
833 let HydrateOutcome::Hydrated { epoch, .. } = out else {
834 panic!("a sequential takeover must hydrate, got {out:?}");
835 };
836 assert_eq!(epoch, claim::FIRST_EPOCH + 1);
837 }
838
839 #[tokio::test]
842 async fn an_empty_prefix_is_nothing_in_the_store_not_an_error() {
843 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
844 let dir = tempdir("virgin");
845 let subjects = vec!["accounts.db".to_string()];
846
847 let out = hydrate(request(&store, &dir, &subjects, "n1")).await.unwrap();
848 let HydrateOutcome::NothingInTheStore { epoch, subjects: missing } = out else {
849 panic!("expected NothingInTheStore, got {out:?}");
850 };
851 assert_eq!(epoch, claim::FIRST_EPOCH);
852 assert_eq!(missing, vec!["accounts.db".to_string()]);
853 assert!(!dir.join("accounts.db").exists());
854 }
855
856 #[tokio::test]
860 async fn a_prefix_holding_only_some_subjects_is_refused() {
861 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
862 seed_snapshot(&store, "wl/acct/accounts.db", b"A").await;
863 let dir = tempdir("half");
864 let subjects = vec!["accounts.db".to_string(), "sessions.db".to_string()];
865
866 let out = hydrate(request(&store, &dir, &subjects, "n1")).await.unwrap();
867 let HydrateOutcome::Refused(HydrateRefusal::TornVolume { populated, absent }) = out else {
868 panic!("expected TornVolume, got {out:?}");
869 };
870 assert_eq!(populated, vec!["accounts.db".to_string()]);
871 assert_eq!(absent, vec!["sessions.db".to_string()]);
872 }
873
874 #[tokio::test]
882 async fn a_takeover_during_the_restore_is_caught_after_the_bytes_land() {
883 let store = racing_store(RivalOn::SnapshotRead, 9, "us-east-001");
884 seed_snapshot(&store, "wl/acct/accounts.db", b"ACCOUNTS").await;
885 let dir = tempdir("midrestore");
886 let subjects = vec!["accounts.db".to_string()];
887
888 let out = hydrate(request(&store, &dir, &subjects, "us-west-003"))
889 .await
890 .unwrap();
891 let HydrateOutcome::Refused(HydrateRefusal::FencedMidRestore { lost, restored }) = out
892 else {
893 panic!("expected FencedMidRestore, got {out:?}");
894 };
895 let ClaimLost::Superseded { current, ours } = &lost else {
896 panic!("expected Superseded, got {lost:?}");
897 };
898 assert_eq!(*ours, claim::FIRST_EPOCH);
899 assert_eq!(current.epoch, 9);
900 assert_eq!(current.owner, "us-east-001");
901 assert_eq!(restored.len(), 1, "the restore itself completed");
902 assert!(
903 dir.join("accounts.db").exists(),
904 "the restored bytes must be left alone; the refusal is what stops the workload"
905 );
906 assert!(
907 HydrateRefusal::FencedMidRestore { lost, restored }
908 .headline()
909 .contains("do not start")
910 );
911 }
912
913 #[derive(Clone, Copy, PartialEq, Eq)]
921 enum RivalOn {
922 ClaimPut,
925 SnapshotRead,
928 }
929
930 #[tokio::test]
936 async fn a_sink_that_ignores_conditional_puts_is_refused_before_any_claim() {
937 let store: Arc<dyn ObjectStore> = Arc::new(UnconditionalStore {
938 inner: Arc::new(InMemory::new()),
939 });
940 seed_snapshot(&store, "wl/acct/accounts.db", b"A").await;
941 let dir = tempdir("degraded");
942 let subjects = vec!["accounts.db".to_string()];
943
944 let out = hydrate(request(&store, &dir, &subjects, "n1")).await.unwrap();
945 let HydrateOutcome::Refused(refusal) = out else {
946 panic!("expected a refusal, got {out:?}");
947 };
948 assert!(
949 matches!(refusal, HydrateRefusal::SinkNotFenced { .. }),
950 "{refusal:?}"
951 );
952 assert!(refusal.headline().contains("conditional writes"), "{}", refusal.headline());
953 assert!(
954 !dir.join("accounts.db").exists(),
955 "nothing may be restored under a fence that is not real"
956 );
957 }
958
959 struct UnconditionalStore {
962 inner: Arc<dyn ObjectStore>,
963 }
964
965 impl std::fmt::Display for UnconditionalStore {
966 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
967 write!(f, "UnconditionalStore({})", self.inner)
968 }
969 }
970 impl std::fmt::Debug for UnconditionalStore {
971 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
972 write!(f, "UnconditionalStore({:?})", self.inner)
973 }
974 }
975
976 #[async_trait::async_trait]
977 impl ObjectStore for UnconditionalStore {
978 async fn put_opts(
979 &self,
980 location: &object_store::path::Path,
981 payload: object_store::PutPayload,
982 _opts: object_store::PutOptions,
983 ) -> object_store::Result<object_store::PutResult> {
984 self.inner
985 .put_opts(location, payload, object_store::PutOptions::default())
986 .await
987 }
988
989 async fn put_multipart_opts(
990 &self,
991 location: &object_store::path::Path,
992 opts: object_store::PutMultipartOptions,
993 ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
994 self.inner.put_multipart_opts(location, opts).await
995 }
996
997 async fn get_opts(
998 &self,
999 location: &object_store::path::Path,
1000 options: object_store::GetOptions,
1001 ) -> object_store::Result<object_store::GetResult> {
1002 self.inner.get_opts(location, options).await
1003 }
1004
1005 fn delete_stream(
1006 &self,
1007 locations: futures_util::stream::BoxStream<
1008 'static,
1009 object_store::Result<object_store::path::Path>,
1010 >,
1011 ) -> futures_util::stream::BoxStream<'static, object_store::Result<object_store::path::Path>>
1012 {
1013 self.inner.delete_stream(locations)
1014 }
1015
1016 fn list(
1017 &self,
1018 prefix: Option<&object_store::path::Path>,
1019 ) -> futures_util::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1020 {
1021 self.inner.list(prefix)
1022 }
1023
1024 async fn list_with_delimiter(
1025 &self,
1026 prefix: Option<&object_store::path::Path>,
1027 ) -> object_store::Result<object_store::ListResult> {
1028 self.inner.list_with_delimiter(prefix).await
1029 }
1030
1031 async fn copy_opts(
1032 &self,
1033 from: &object_store::path::Path,
1034 to: &object_store::path::Path,
1035 options: object_store::CopyOptions,
1036 ) -> object_store::Result<()> {
1037 self.inner.copy_opts(from, to, options).await
1038 }
1039 }
1040
1041 struct RacingStore {
1042 inner: Arc<dyn ObjectStore>,
1043 claim_key: object_store::path::Path,
1044 when: RivalOn,
1045 rival_epoch: u64,
1046 rival_owner: String,
1047 fired: std::sync::atomic::AtomicBool,
1048 }
1049
1050 impl RacingStore {
1051 async fn fire(&self) -> object_store::Result<()> {
1052 if self
1053 .fired
1054 .swap(true, std::sync::atomic::Ordering::SeqCst)
1055 {
1056 return Ok(());
1057 }
1058 let body = format!("{} 1 {}\n", self.rival_epoch, self.rival_owner);
1059 self.inner
1060 .put(&self.claim_key, body.into_bytes().into())
1061 .await?;
1062 Ok(())
1063 }
1064 }
1065
1066 impl std::fmt::Display for RacingStore {
1067 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1068 write!(f, "RacingStore({})", self.inner)
1069 }
1070 }
1071 impl std::fmt::Debug for RacingStore {
1072 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1073 write!(f, "RacingStore({:?})", self.inner)
1074 }
1075 }
1076
1077 #[async_trait::async_trait]
1078 impl ObjectStore for RacingStore {
1079 async fn put_opts(
1080 &self,
1081 location: &object_store::path::Path,
1082 payload: object_store::PutPayload,
1083 opts: object_store::PutOptions,
1084 ) -> object_store::Result<object_store::PutResult> {
1085 if self.when == RivalOn::ClaimPut && location == &self.claim_key {
1086 self.fire().await?;
1087 }
1088 self.inner.put_opts(location, payload, opts).await
1089 }
1090
1091 async fn put_multipart_opts(
1092 &self,
1093 location: &object_store::path::Path,
1094 opts: object_store::PutMultipartOptions,
1095 ) -> object_store::Result<Box<dyn object_store::MultipartUpload>> {
1096 self.inner.put_multipart_opts(location, opts).await
1097 }
1098
1099 async fn get_opts(
1100 &self,
1101 location: &object_store::path::Path,
1102 options: object_store::GetOptions,
1103 ) -> object_store::Result<object_store::GetResult> {
1104 self.inner.get_opts(location, options).await
1105 }
1106
1107 fn delete_stream(
1108 &self,
1109 locations: futures_util::stream::BoxStream<
1110 'static,
1111 object_store::Result<object_store::path::Path>,
1112 >,
1113 ) -> futures_util::stream::BoxStream<'static, object_store::Result<object_store::path::Path>>
1114 {
1115 self.inner.delete_stream(locations)
1116 }
1117
1118 fn list(
1119 &self,
1120 prefix: Option<&object_store::path::Path>,
1121 ) -> futures_util::stream::BoxStream<'static, object_store::Result<object_store::ObjectMeta>>
1122 {
1123 self.inner.list(prefix)
1124 }
1125
1126 async fn list_with_delimiter(
1127 &self,
1128 prefix: Option<&object_store::path::Path>,
1129 ) -> object_store::Result<object_store::ListResult> {
1130 if self.when == RivalOn::SnapshotRead
1131 && prefix.is_some_and(|p| p.as_ref().ends_with("snapshots"))
1132 {
1133 self.fire().await?;
1134 }
1135 self.inner.list_with_delimiter(prefix).await
1136 }
1137
1138 async fn copy_opts(
1139 &self,
1140 from: &object_store::path::Path,
1141 to: &object_store::path::Path,
1142 options: object_store::CopyOptions,
1143 ) -> object_store::Result<()> {
1144 self.inner.copy_opts(from, to, options).await
1145 }
1146 }
1147
1148 fn racing_store(when: RivalOn, rival_epoch: u64, rival_owner: &str) -> Arc<dyn ObjectStore> {
1149 Arc::new(RacingStore {
1150 inner: Arc::new(InMemory::new()),
1151 claim_key: object_store::path::Path::from("wl/acct/latest.owner-claim"),
1152 when,
1153 rival_epoch,
1154 rival_owner: rival_owner.to_string(),
1155 fired: std::sync::atomic::AtomicBool::new(false),
1156 })
1157 }
1158
1159 #[tokio::test]
1163 async fn a_nested_subject_gets_its_directory_created() {
1164 let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
1165 seed_snapshot(&store, "wl/acct/db/accounts.db", b"NESTED").await;
1166 let dir = tempdir("nested");
1167 let subjects = vec!["db/accounts.db".to_string()];
1168
1169 let out = hydrate(request(&store, &dir, &subjects, "n1")).await.unwrap();
1170 assert!(matches!(out, HydrateOutcome::Hydrated { .. }), "{out:?}");
1171 assert_eq!(
1172 std::fs::read(dir.join("db/accounts.db")).unwrap(),
1173 db_image(b"NESTED")
1174 );
1175 }
1176}