Skip to main content

turso_backup/
hydrate.rs

1//! R850-F1 — **hydrate-on-place**: materialise a workload's declared databases
2//! into an empty volume before the workload starts, under a fence.
3//!
4//! The three restore entry points this crate already has
5//! ([`snapshot::restore_latest`], [`dedup::restore_latest_dedup`],
6//! [`stream::restore_latest_stream`]) each take one database path and assume
7//! the caller decided that a restore should happen. This module is that
8//! decision, for the shape R850 is actually about: **one volume, several
9//! databases, one node that may or may not be allowed to bring them up.**
10//!
11//! # The trigger is emptiness, and emptiness has three answers not two
12//!
13//! The rule R850 filed was "restore when the named volume is EMPTY, so a normal
14//! restart never re-hydrates". That is right and it is incomplete, because a
15//! workload with three databases has a third state. [`assess`] names all three:
16//!
17//! - **every subject absent** → [`Readiness::Hydrate`]. A fresh node, or a
18//!   node taking over after the old one died.
19//! - **every subject present** → [`Readiness::AlreadyPopulated`]. An ordinary
20//!   restart. Re-hydrating here would roll the databases back to the last
21//!   watermark and silently destroy everything written since — the single worst
22//!   thing this module could do, so it is the case with the loudest test.
23//! - **some of each** → [`Readiness::TornVolume`], and it is **refused**.
24//!   Restoring only the absent ones would reconstruct a set of databases at
25//!   different points in time; for the driving case (accounts, passkeys and
26//!   sessions that reference each other) that is a live application whose data
27//!   silently disagrees with itself. There is no safe automatic answer, so the
28//!   operator gets a refusal that names both lists instead of a repair that
29//!   looks like it worked.
30//!
31//! # The fence, and why it is checked twice
32//!
33//! Ownership comes from [`claim`], not from this module — see that module for
34//! why the object store is the authority an appliance has. Here it is used at
35//! two points, and the second is the one that is easy to leave out:
36//!
37//! 1. Before reading a byte, [`claim::acquire`]. Losing means another node is
38//!    placing the same appliance *right now*, and the loser must not hydrate.
39//! 2. After the last subject lands and before the caller starts anything,
40//!    [`claim::assert_holds`]. A restore is seconds to minutes long and is not
41//!    atomic; a takeover that lands in the middle leaves this node holding a
42//!    complete, plausible, *stale* copy of somebody else's database.
43//!
44//! On (2) the restored bytes are deliberately **left on disk**. Deleting a
45//! database because a fence moved is a worse failure than refusing to start
46//! with it, and the caller has the outcome it needs to refuse.
47//!
48//! What this module cannot do is stop the *losing* node's workload: it is not a
49//! supervisor and has no handle on the process. Whoever calls this must treat
50//! [`HydrateOutcome::Refused`] as "do not start", and must treat a later
51//! [`stream::StreamOutcome::Fenced`] as "stop what you started". That
52//! obligation is the whole reason it is stated here rather than assumed.
53//!
54//! # Object layout
55//!
56//! Each subject hangs at `<store_prefix>/<subject>`, so a volume containing
57//! `db/accounts.db` backs up under `.../db/accounts.db/{snapshots,frames,…}`
58//! and an operator listing the bucket sees the layout they see on the volume.
59//! The **claim is one level up**, on `<store_prefix>` itself: at-most-one-live
60//! is a property of the workload, not of each file, and per-file claims would
61//! let a node win two of three and hydrate a torn set legitimately.
62//!
63//! [`snapshot::restore_latest`]: crate::snapshot::restore_latest
64//! [`dedup::restore_latest_dedup`]: crate::dedup::restore_latest_dedup
65//! [`stream::restore_latest_stream`]: crate::stream::restore_latest_stream
66//! [`claim`]: crate::claim
67//! [`claim::acquire`]: crate::claim::acquire
68//! [`claim::assert_holds`]: crate::claim::assert_holds
69//! [`stream::StreamOutcome::Fenced`]: crate::stream::StreamOutcome::Fenced
70
71use 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/// The bytes-shipping half of a durability tier.
83///
84/// Deliberately not `workload_spec::DurabilityTier`: that enum has a `None`
85/// arm, and a tier that ships nothing has no hydrate path — carrying it here
86/// would mean an unreachable arm in every match. turso-backup also takes no
87/// dependency on yah types (the same stance `stream::StreamConfig::epoch`
88/// takes toward `yah_tenant_pointer`), so the caller translates.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Tier {
91    /// Tier 1a — newest full `VACUUM INTO` snapshot.
92    Snapshot,
93    /// Tier 1b — newest page-dedup manifest.
94    Dedup,
95    /// Tier 2 — base snapshot plus WAL-frame replay. Falls back to the base
96    /// alone when no generations have been written yet, which is the state a
97    /// stream-tier prefix is in between its first snapshot and its first tail.
98    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/// Whether one subject already exists on the volume.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum SubjectState {
120    /// No file, or a zero-length one.
121    ///
122    /// Zero counts as absent on purpose: a zero-byte file is not a database,
123    /// and treating one as populated would wedge a node into
124    /// [`Readiness::AlreadyPopulated`] forever after a truncated write or a
125    /// bind-mount that touched the path into existence.
126    Absent,
127    /// A file with content. Its size is carried only for the diagnostic in
128    /// [`Readiness::TornVolume`] — nothing decides on it.
129    Populated { bytes: u64 },
130}
131
132impl SubjectState {
133    fn is_absent(&self) -> bool {
134        matches!(self, SubjectState::Absent)
135    }
136}
137
138/// What the state of the volume says should happen. See the module doc.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum Readiness {
141    /// Every subject is absent.
142    Hydrate,
143    /// Every subject is present. An ordinary restart.
144    AlreadyPopulated,
145    /// Some of each. Refused — see the module doc.
146    TornVolume {
147        populated: Vec<String>,
148        absent: Vec<String>,
149    },
150}
151
152/// Decide from the observed volume state alone. Pure — the I/O is
153/// [`inspect_volume`], split out so this half is testable without a filesystem
154/// and so a caller that already knows the state need not stat twice.
155///
156/// An empty `states` is an error, not a `Hydrate`: a bytes-shipping tier with
157/// no subjects is a declaration that names nothing, and treating it as "restore
158/// everything" (or as "nothing to do") both read a mistake as an intention.
159pub 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        // Both empty is unreachable given the guard above.
173        (true, true) => unreachable!("states was checked non-empty"),
174    }
175}
176
177/// Stat each subject under `volume_root`.
178///
179/// `subjects` are volume-relative and are joined verbatim, so a caller passing
180/// an absolute or `..`-bearing path would escape the volume. Validating that is
181/// the declaration's job
182/// (`workload_spec::WorkloadSpec::durability` refuses both at parse time), and
183/// it is re-checked here rather than trusted, because this function's argument
184/// can come from anywhere and its result decides where bytes get written.
185pub 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
206/// Join a volume-relative subject onto the volume root, refusing anything that
207/// would land outside it.
208///
209/// Public since R850-F1's tail half: the backup side must resolve exactly the
210/// same subject to exactly the same file the restore side writes, and a second
211/// implementation of that join is a second traversal check to get wrong.
212pub 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/// One subject's restore, with what it cost.
229#[derive(Debug, Clone, PartialEq)]
230pub struct SubjectRestore {
231    pub subject: String,
232    /// The object key the bytes came from — a snapshot key, a manifest key, or
233    /// the base snapshot key of a replayed chain.
234    pub source: String,
235    /// Bytes on disk after the restore.
236    pub bytes: u64,
237    /// Wall-clock seconds this subject took. **Measured, not modelled** — the
238    /// distinction `topology::RecoveryEstimate` has to make in the other
239    /// direction, where a declared `state-mb` is multiplied by a constant
240    /// measured once on one host.
241    pub seconds: f64,
242    /// Frames replayed, for [`Tier::Stream`] only. `None` for the snapshot
243    /// tiers, and `Some(0)` for a stream prefix that had a base but no
244    /// generations yet — those are different facts and the report keeps them
245    /// different.
246    pub frames_replayed: Option<u64>,
247}
248
249/// What [`hydrate`] did.
250#[derive(Debug, Clone, PartialEq)]
251pub enum HydrateOutcome {
252    /// Bytes landed. `epoch` is the fencing token to stream under, and it was
253    /// re-verified after the last subject.
254    Hydrated {
255        epoch: u64,
256        subjects: Vec<SubjectRestore>,
257        /// Wall-clock seconds for the whole set, including the claim round
258        /// trips. This is the number a recovery-time estimate should be
259        /// replaced by once it exists.
260        seconds: f64,
261    },
262    /// The volume already holds every subject. Nothing was read and **no claim
263    /// was taken** — an ordinary restart must not mint an epoch, because doing
264    /// so would fence out a streamer that is legitimately still running against
265    /// the same volume from a previous incarnation the supervisor has not
266    /// reaped yet.
267    AlreadyPopulated,
268    /// The claim was taken and the store holds nothing for these subjects — a
269    /// first placement. The caller starts the workload empty and the backup
270    /// side creates the first snapshot.
271    NothingInTheStore { epoch: u64, subjects: Vec<String> },
272    /// Do not start the workload.
273    Refused(HydrateRefusal),
274}
275
276/// Every way a hydrate says "do not start this workload".
277#[derive(Debug, Clone, PartialEq)]
278pub enum HydrateRefusal {
279    /// Some subjects present, some absent. See [`Readiness::TornVolume`].
280    TornVolume {
281        populated: Vec<String>,
282        absent: Vec<String>,
283    },
284    /// Lost the claim race at step 1 — another node is placing this workload
285    /// concurrently.
286    ClaimLost { current: ClaimRecord },
287    /// Held the claim, restored, and lost it before the check at step 3. The
288    /// restored bytes are still on disk (see the module doc) and belong to the
289    /// node named in `lost`.
290    FencedMidRestore {
291        lost: ClaimLost,
292        restored: Vec<SubjectRestore>,
293    },
294    /// The sink does not enforce conditional puts, so the claim's
295    /// compare-and-swap would silently degrade to last-write-wins and *both*
296    /// nodes would believe they won.
297    ///
298    /// This is a property of the deployed bucket, not of this code, so the only
299    /// guard that can exist is a runtime probe — the same one
300    /// `tenant-streamer::verify_sink` refuses to start on, made a refusal here
301    /// rather than a caller's responsibility because a caller who forgets gets
302    /// a fence that isn't there and no way to tell.
303    SinkNotFenced { stage: PreflightStage },
304}
305
306impl HydrateRefusal {
307    /// One line an operator can act on.
308    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
341/// Everything [`hydrate`] needs.
342pub struct HydrateRequest<'a> {
343    /// The object store both the claim and the data live in.
344    pub store: Arc<dyn ObjectStore>,
345    /// Key prefix for this *workload*. The claim sits here; each subject hangs
346    /// one level below.
347    pub store_prefix: &'a str,
348    /// Host directory the named volume is bound from, e.g.
349    /// `/var/lib/yah/kamaji/volumes/<name>`.
350    pub volume_root: &'a Path,
351    /// Volume-relative database paths, in declaration order.
352    pub subjects: &'a [String],
353    pub tier: Tier,
354    /// Label recorded in the claim. Diagnostic; see [`ClaimRecord::owner`].
355    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
379/// Bring a workload's declared state onto this node, or say why not.
380///
381/// The order is the protocol from [`claim`]'s module doc, and it is the order
382/// for a reason: emptiness is checked *before* the claim so an ordinary restart
383/// costs no epoch, and the claim is re-verified *after* the last byte so a
384/// takeover during a long restore cannot go unnoticed.
385///
386/// An `Err` means the hydrate could not reach a verdict — an unreachable store,
387/// an unreadable volume. Callers must treat that as "do not start" too, for the
388/// same reason [`claim::assert_holds`] does: an unreachable store is
389/// indistinguishable from the partition the fence exists for.
390pub 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    // Prove the fence is real before leaning on it. Deliberately *after* the
407    // readiness check: an ordinary restart takes no claim, so it should not pay
408    // for a probe or be blocked by a degraded sink it never writes to.
409    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        // A subject in a subdirectory of the volume needs that subdirectory to
431        // exist; runc's bind mount does not create it and neither does the
432        // restore, which writes a file.
433        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    // A prefix that holds some subjects and not others is the store-side twin
462    // of `TornVolume`, and it is refused for the identical reason: restoring
463    // the ones that exist would start the workload with a partial history it
464    // cannot tell apart from a complete one.
465    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    // Step 3: the claim must still be ours, checked *after* the bytes landed.
473    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
493/// Restore one subject, or `Ok(None)` when the store holds nothing for it.
494///
495/// "Nothing there" has to be a value rather than an error: a first placement
496/// legitimately finds an empty prefix, and the three restore entry points all
497/// signal it with an `Err` that is indistinguishable at the type level from a
498/// real failure.
499async 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            // A stream prefix between its first tier-1a snapshot and its first
519            // tail has a base and no generations. Replaying nothing onto the
520            // base is the correct restore, and it is what roadcase's
521            // `SinkHydrator` already does — see `restore_stream_from_manifests`.
522            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
540/// Whether an error from a restore entry point means "the prefix is empty"
541/// rather than "the restore failed".
542///
543/// String matching, which is ugly, and the honest alternative is worse: the
544/// three entry points return `anyhow::Error` and signal absence through
545/// `Context` messages, so the only structural fix is to change their return
546/// types — a wider change than this ticket, and one that would ripple into
547/// roadcase. The two phrases matched here are pinned by
548/// `an_empty_prefix_is_nothing_in_the_store_not_an_error`, so a reword on
549/// either side fails a test rather than turning a first placement into a
550/// startup failure.
551fn 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    // ── the decision core ───────────────────────────────────────────────────
569
570    #[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    /// The case R850's one-line rule does not cover and the driving question
583    /// makes the default. Restoring only `b.db` would bring the workload up
584    /// with two databases at different points in time.
585    #[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    /// A zero-byte file is not a database. Counting one as populated would wedge
603    /// a node out of ever hydrating after a truncated write.
604    #[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    /// The declaration already refuses these (`workload_spec`'s
623    /// `AbsoluteSubject` / `TraversingSubject`), and this is the belt to that
624    /// braces — the argument can come from anywhere and its result decides
625    /// where bytes get written.
626    #[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    // ── the fenced execution path ───────────────────────────────────────────
645
646    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    /// A byte string `upload_base_snapshot` will accept — it refuses anything
660    /// without the SQLite magic, so a hand-rolled fixture has to carry it.
661    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    /// Seed a tier-1a snapshot for `subject` under `prefix`, so a hydrate has
668    /// something to find. `upload_base_snapshot` is the real writer path.
669    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        // The report carries measured bytes, not a modelled figure.
720        assert_eq!(r[0].bytes, db_image(b"ACCOUNTS").len() as u64);
721
722        // ...and the claim is now readable by anyone else asking who owns this.
723        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    /// The single worst thing this module could do. An ordinary restart must
734    /// not roll the databases back to the last watermark — and must not even
735    /// mint an epoch, since that would fence a streamer still running against
736    /// the same volume.
737    #[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    /// Step 1 of the protocol: another node is placing this workload right now.
786    ///
787    /// `RivalOn::ClaimPut` stages the interleaving `acquire`'s CAS exists to
788    /// catch — the rival's claim lands between our read of the sidecar and our
789    /// conditional write of it.
790    #[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    /// The other half of the same rule, and the one it would be easy to break
812    /// by making `acquire` refuse whenever anyone else holds the prefix:
813    /// failover must remain possible. A *sequential* takeover hydrates.
814    #[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    /// A first placement finds an empty prefix. That has to be a value, not the
840    /// startup failure the underlying `restore_latest` reports it as.
841    #[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    /// The store-side twin of `TornVolume`: half the subjects have backups and
857    /// half do not. Starting with the half that restored would look identical
858    /// to a complete restore.
859    #[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    /// Step 3 of the protocol, and the check that is easy to leave out: a
875    /// takeover landing mid-restore leaves this node holding a complete,
876    /// plausible, *stale* copy. Only the post-restore `assert_holds` can see
877    /// it — the pre-restore acquire succeeded, and every byte arrived.
878    ///
879    /// The bytes stay on disk deliberately: deleting a database because a fence
880    /// moved is a worse failure than refusing to start with it.
881    #[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    /// A store that lets a rival node's claim land at a chosen point in our own
914    /// hydrate — the two interleavings the fence exists for, neither of which
915    /// can be staged by calling `acquire` twice (a sequential second acquire is
916    /// a legal takeover, not a race).
917    ///
918    /// Modelled on `stream::tests::RacingStore`, which does the same job for
919    /// the watermark CAS.
920    #[derive(Clone, Copy, PartialEq, Eq)]
921    enum RivalOn {
922        /// Fire on the first put aimed at the claim key: the rival's record is
923        /// already there when our conditional put lands, so `acquire` loses.
924        ClaimPut,
925        /// Fire on the first listing of a `snapshots/` prefix — i.e. once we
926        /// hold the claim and the restore has started.
927        SnapshotRead,
928    }
929
930    /// The fence rests on the bucket honouring `If-Match` / `If-None-Match`,
931    /// which is a deployment property no test of this code can establish. A
932    /// bucket that ignores them lets *both* nodes' claims succeed — so a
933    /// hydrate against one must refuse rather than proceed under a fence that
934    /// is not there.
935    #[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    /// Drops every put precondition on the floor — a store that has silently
960    /// degraded to last-write-wins.
961    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    /// A subject in a subdirectory of the volume needs that directory created —
1160    /// runc's bind mount does not create it and the restore writes a file, not
1161    /// a tree.
1162    #[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}