Skip to main content

vcon_server/store/
backup.rs

1//! One tenant's Store copied into a backup while a server may be writing
2//! to it: the catalog with SQLite's online backup API, then the object file
3//! of every version row that copy holds.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::io;
7use std::path::Path;
8
9use super::Store;
10use super::packed::Unread;
11use crate::backend::{ObjectError, Packed};
12use crate::ids::VersionId;
13
14/// How many times the catalog is copied while versions in each copy are
15/// purged before their object files are copied.
16pub const TRIES: usize = 5;
17
18/// What `backup_to` copied.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct Copied {
21    /// Versions kept, hidden ones included.
22    pub versions: u64,
23    /// How many times the catalog was copied.
24    pub tries: usize,
25}
26
27/// Why a backup of a Store failed.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum BackupError {
30    /// A version in each of `TRIES` copies of the catalog was purged before
31    /// its object file could be copied.
32    KeptChanging,
33    /// A file could not be read, written or synced, or the catalog could
34    /// not be copied.
35    Io,
36}
37
38impl Store {
39    /// Copies this tenant's catalog and object files into `dir`, made if
40    /// new. Anything already in `dir` from an earlier try is replaced or
41    /// removed.
42    ///
43    /// Each try copies the catalog first, in one step inside one read
44    /// transaction, then the object file of each version row in that copy.
45    /// An object file is written once and never changed, so a file copied
46    /// at any try is still right; a row whose file is gone was purged after
47    /// the copy, and the next try copies the catalog again. Files the final
48    /// copy has no row for are removed. Every file and directory written is
49    /// synced, so the backup is whole once this returns.
50    pub fn backup_to(&self, dir: &Path) -> Result<Copied, BackupError> {
51        let fail = |_| BackupError::Io;
52        let objects = dir.join("objects");
53        self.fs.create_dir_all(&objects).map_err(fail)?;
54        let catalog = dir.join("catalog.sqlite");
55        for tries in 1..=TRIES {
56            for file in ["catalog.sqlite", "catalog.sqlite-wal", "catalog.sqlite-shm"] {
57                remove_if_there(&dir.join(file)).map_err(fail)?;
58            }
59            let places: BTreeMap<String, Option<Packed>> = self
60                .catalog
61                .backup_into(&catalog)
62                .map_err(|_| BackupError::Io)?
63                .into_iter()
64                .map(|(v, place)| (v.to_string(), place))
65                .collect();
66            let versions: BTreeSet<String> = places.keys().cloned().collect();
67            #[cfg(test)]
68            if let Some(hook) = self.hooks.copied.lock().expect("hook").as_mut() {
69                hook(self);
70            }
71            let present: BTreeSet<String> = self
72                .fs
73                .list(&objects)
74                .map_err(fail)?
75                .into_iter()
76                .filter_map(|n| n.into_string().ok())
77                .collect();
78            let mut missing = false;
79            for name in &versions {
80                if present.contains(name) {
81                    continue;
82                }
83                let Some(version) = VersionId::parse(name) else {
84                    return Err(BackupError::Io);
85                };
86                // A packed version's blob is read from its row and written
87                // as an object file, as any other version's is.
88                let place = places.get(name).and_then(Option::as_ref);
89                match self.sealed_blob(version, place) {
90                    Ok(bytes) => {
91                        let tmp = objects.join(format!("{name}.tmp"));
92                        if present.contains(&format!("{name}.tmp")) {
93                            self.fs.remove(&tmp).map_err(fail)?;
94                        }
95                        self.fs.write_new(&tmp, &bytes).map_err(fail)?;
96                        self.fs.sync_file(&tmp).map_err(fail)?;
97                        self.fs.rename(&tmp, &objects.join(name)).map_err(fail)?;
98                    }
99                    // Purged since the copy, or its file rewritten since:
100                    // the next try copies the catalog again.
101                    Err(Unread::Object(ObjectError::Missing)) => missing = true,
102                    Err(Unread::Object(ObjectError::Io(e))) => return Err(fail(e)),
103                    Err(Unread::Damaged) => return Err(BackupError::Io),
104                }
105            }
106            if missing {
107                continue;
108            }
109            // The copy is a data directory of objects: every version's blob
110            // was written above as an object file.
111            self.catalog
112                .unpack_copy(&catalog)
113                .map_err(|_| BackupError::Io)?;
114            for name in self.fs.list(&objects).map_err(fail)? {
115                let name = name.into_string().unwrap_or_default();
116                if !versions.contains(&name) {
117                    self.fs.remove(&objects.join(&name)).map_err(fail)?;
118                }
119            }
120            self.fs.sync_dir(&objects).map_err(fail)?;
121            self.fs.sync_dir(dir).map_err(fail)?;
122            // A new directory survives a crash once the one holding it is
123            // synced too.
124            if let Some(parent) = dir.parent() {
125                self.fs.sync_dir(parent).map_err(fail)?;
126            }
127            return Ok(Copied {
128                versions: versions.len() as u64,
129                tries,
130            });
131        }
132        Err(BackupError::KeptChanging)
133    }
134}
135
136fn remove_if_there(path: &Path) -> io::Result<()> {
137    match std::fs::remove_file(path) {
138        Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
139        _ => Ok(()),
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use std::sync::atomic::{AtomicUsize, Ordering};
146    use std::sync::{Arc, Mutex};
147
148    use super::*;
149    use crate::clock::Clock;
150    use crate::fs::Fs;
151    use crate::retention::Retention;
152    use crate::store::testing::vcon;
153    use crate::store::tests::{Fixture, fixture};
154
155    const A: &str = "01a07da8-c2bb-83e5-b9a2-279e0d16bc46";
156    const B: &str = "01a07da8-c2bb-83e5-b9a2-279e0d16bc47";
157
158    /// Stores A (then replaces it, so one version is hidden) and B.
159    fn stored(f: &Fixture) -> VersionId {
160        let (found, body) = vcon(A, "first");
161        let first = match f.store.create(&f.caller, &found, &body).expect("create") {
162            crate::store::Created::New(v) => v,
163            other => panic!("{other:?}"),
164        };
165        let (found, body) = vcon(A, "second");
166        f.store
167            .replace(&f.caller, first, &found, &body)
168            .expect("replace");
169        let (found, body) = vcon(B, "other");
170        f.store.create(&f.caller, &found, &body).expect("create");
171        first
172    }
173
174    /// The backup in `dir` opened as a Store, and examined.
175    fn examined(f: &Fixture, dir: &Path) -> (Store, crate::store::Examination) {
176        let store = Store::open_beside(
177            dir,
178            f.tenant(),
179            f.tenant_key(),
180            f.fs.clone(),
181            f.clock.clone(),
182        )
183        .expect("the backup opens under the tenant key");
184        let now = f.clock.now();
185        let found = store
186            .examine(Retention::Years(7), now, now)
187            .expect("examines");
188        (store, found)
189    }
190
191    fn body_of(store: &Store, f: &Fixture, uuid: &str) -> Vec<u8> {
192        store
193            .get(&f.caller, uuid.parse().expect("uuid"))
194            .expect("reads")
195            .body
196    }
197
198    /// A backup of a Store whose versions are packed is a data directory
199    /// like any other: each version's sealed bytes in an object file of its
200    /// own, no place and no pack file in its catalog. It opens with no pack
201    /// storage, and every version reads back.
202    #[test]
203    fn a_backup_of_packed_versions_holds_each_in_an_object_file() {
204        let mut f = fixture();
205        let hidden = stored(&f);
206        let sealed: std::collections::BTreeMap<String, Vec<u8>> = f
207            .store
208            .objects
209            .list()
210            .expect("listed")
211            .into_iter()
212            .map(|name| {
213                let v = VersionId::parse(&name).expect("a version");
214                (name, f.store.objects.get(v).expect("its object"))
215            })
216            .collect();
217        crate::store::packer::tests::with_packs(&mut f);
218        f.store
219            .pack(&crate::store::packer::PackPlan::with_limit(1 << 20))
220            .expect("packs");
221        assert!(
222            f.store.objects.list().expect("listed").is_empty(),
223            "all packed"
224        );
225        let dir = f.dir.path().join("backup");
226        let copied = f.store.backup_to(&dir).expect("backs up");
227        assert_eq!(copied.versions, 3, "every version");
228        for (name, bytes) in &sealed {
229            assert_eq!(
230                &f.fs
231                    .read(&dir.join("objects").join(name))
232                    .expect("an object file"),
233                bytes,
234                "the sealed bytes, byte for byte"
235            );
236        }
237        let (store, found) = examined(&f, &dir);
238        assert!(
239            found.damaged.is_empty(),
240            "nothing damaged: {:?}",
241            found.damaged
242        );
243        assert_eq!(body_of(&store, &f, A), vcon(A, "second").1, "A reads back");
244        assert_eq!(body_of(&store, &f, B), vcon(B, "other").1, "B reads back");
245        assert_eq!(
246            store
247                .legal_get(&f.caller, A.parse().expect("uuid"), Some(hidden))
248                .expect("a legal read")
249                .stored
250                .body,
251            vcon(A, "first").1,
252            "the hidden version reads back"
253        );
254        let conn = store.conn_for_test();
255        let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).expect("counted") };
256        assert_eq!(
257            count("SELECT count(*) FROM version WHERE place_file IS NOT NULL"),
258            0,
259            "no place"
260        );
261        assert_eq!(
262            count("SELECT count(*) FROM parquet_file"),
263            0,
264            "no pack file"
265        );
266    }
267
268    #[test]
269    fn a_backup_opens_whole_with_every_version() {
270        let f = fixture();
271        stored(&f);
272        let dir = f.dir.path().join("backup");
273        let copied = f.store.backup_to(&dir).expect("backs up");
274        assert_eq!(
275            copied,
276            Copied {
277                versions: 3,
278                tries: 1
279            }
280        );
281        let (store, found) = examined(&f, &dir);
282        assert_eq!(found.versions, 3);
283        assert!(found.damaged.is_empty(), "{:?}", found.damaged);
284        assert!(found.leftovers.is_empty(), "{:?}", found.leftovers);
285        assert_eq!(body_of(&store, &f, A), body_of(&f.store, &f, A));
286        assert_eq!(body_of(&store, &f, B), body_of(&f.store, &f, B));
287    }
288
289    #[test]
290    fn a_version_purged_after_the_catalog_is_copied_makes_another_try() {
291        let f = fixture();
292        let first = stored(&f);
293        let calls = Arc::new(AtomicUsize::new(0));
294        let seen = calls.clone();
295        *f.store.hooks.copied.lock().expect("hook") = Some(Box::new(move |store: &Store| {
296            // Once: the hidden version is purged, row and file, after the
297            // catalog copy holds its row.
298            if seen.fetch_add(1, Ordering::SeqCst) == 0 {
299                let purged = store.purge_due(Retention::Days(0)).expect("purges");
300                assert_eq!(purged.purged, 1);
301            }
302        }));
303        let dir = f.dir.path().join("backup");
304        let copied = f.store.backup_to(&dir).expect("backs up");
305        assert_eq!(
306            copied,
307            Copied {
308                versions: 2,
309                tries: 2
310            }
311        );
312        let (_, found) = examined(&f, &dir);
313        assert_eq!(found.versions, 2);
314        assert!(found.damaged.is_empty(), "{:?}", found.damaged);
315        assert!(found.leftovers.is_empty(), "{:?}", found.leftovers);
316        assert!(
317            !dir.join("objects").join(first.to_string()).exists()
318                && f.fs
319                    .read(&dir.join("objects").join(first.to_string()))
320                    .is_err(),
321            "the purged version's file is not in the backup"
322        );
323    }
324
325    #[test]
326    fn a_store_whose_versions_keep_being_purged_fails_after_its_tries() {
327        let f = fixture();
328        stored(&f);
329        let calls = Arc::new(AtomicUsize::new(0));
330        let seen = calls.clone();
331        let current = Arc::new(Mutex::new(None::<VersionId>));
332        *f.store.hooks.copied.lock().expect("hook") = Some(Box::new(move |store: &Store| {
333            // Each try: B's current version is replaced and purged at
334            // once, so the copy's row for it never has its file.
335            let n = seen.fetch_add(1, Ordering::SeqCst);
336            let mut current = current.lock().expect("current");
337            let caller = crate::store::Caller {
338                key: crate::envelope::Key::generate().expect("key").id(),
339            };
340            let now = current.unwrap_or_else(|| {
341                store
342                    .get(&caller, B.parse().expect("uuid"))
343                    .expect("B")
344                    .version
345            });
346            let (found, body) = vcon(B, &format!("change {n}"));
347            *current = Some(store.replace(&caller, now, &found, &body).expect("replace"));
348            store.purge_due(Retention::Days(0)).expect("purges");
349        }));
350        let dir = f.dir.path().join("backup");
351        assert_eq!(f.store.backup_to(&dir), Err(BackupError::KeptChanging));
352        assert_eq!(calls.load(Ordering::SeqCst), TRIES);
353    }
354
355    #[test]
356    fn files_an_earlier_try_left_are_replaced_or_removed() {
357        let f = fixture();
358        stored(&f);
359        let dir = f.dir.path().join("backup");
360        let objects = dir.join("objects");
361        f.fs.create_dir_all(&objects).expect("dir");
362        let stray = VersionId::generate().to_string();
363        f.fs.write_new(&objects.join(&stray), b"from an earlier try")
364            .expect("write");
365        f.fs.write_new(&objects.join(format!("{stray}.tmp")), b"half")
366            .expect("write");
367        std::fs::write(dir.join("catalog.sqlite"), b"an earlier copy").expect("write");
368        std::fs::write(dir.join("catalog.sqlite-wal"), b"its wal").expect("write");
369        f.store.backup_to(&dir).expect("backs up");
370        let (_, found) = examined(&f, &dir);
371        assert_eq!(found.versions, 3);
372        assert!(found.damaged.is_empty(), "{:?}", found.damaged);
373        assert!(found.leftovers.is_empty(), "{:?}", found.leftovers);
374        let names = f.fs.list(&objects).expect("list");
375        assert_eq!(names.len(), 3, "{names:?}");
376    }
377
378    #[test]
379    fn a_backup_survives_a_crash_once_it_returns() {
380        let f = fixture();
381        stored(&f);
382        let dir = f.dir.path().join("backup");
383        f.store.backup_to(&dir).expect("backs up");
384        f.fs.crash();
385        let (_, found) = examined(&f, &dir);
386        assert_eq!(found.versions, 3);
387        assert!(found.damaged.is_empty(), "{:?}", found.damaged);
388    }
389
390    #[test]
391    fn a_backup_taken_while_writes_go_on_opens_whole() {
392        let f = Arc::new(fixture());
393        stored(&f);
394        let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
395        // The writer says when its first create is done, so the backup
396        // starts with writes under way whatever the scheduler does.
397        let (first, written_once) = std::sync::mpsc::channel();
398        let writer = {
399            let (f, stop) = (f.clone(), stop.clone());
400            std::thread::spawn(move || {
401                let mut n = 0u32;
402                while n == 0 || !stop.load(Ordering::SeqCst) {
403                    let uuid = format!("01a07da8-c2bb-83e5-b9a2-{n:012x}");
404                    let (found, body) = vcon(&uuid, "load");
405                    f.store.create(&f.caller, &found, &body).expect("create");
406                    if n == 0 {
407                        let _ = first.send(());
408                    }
409                    n += 1;
410                }
411                n
412            })
413        };
414        written_once
415            .recv_timeout(std::time::Duration::from_secs(20))
416            .expect("the writer stores its first vCon");
417        let dir = f.dir.path().join("backup");
418        let copied = f.store.backup_to(&dir).expect("backs up");
419        stop.store(true, Ordering::SeqCst);
420        let written = writer.join().expect("writer");
421        assert!(written > 0, "writes went on");
422        let (_, found) = examined(&f, &dir);
423        assert_eq!(
424            found.versions, copied.versions,
425            "the copy holds the versions the backup says it copied"
426        );
427        assert!(
428            found.damaged.is_empty(),
429            "every copied version opens: {:?}",
430            found.damaged
431        );
432        assert!(
433            found.leftovers.is_empty(),
434            "the copy holds no file its catalog does not name: {:?}",
435            found.leftovers
436        );
437    }
438}