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