Skip to main content

dpm_store/sqlite/
recovery.rs

1//! Consistent backup, restore into a new location, and read-only integrity verification.
2//!
3//! Copying the database file of a live WAL store can miss committed pages still in `-wal` or tear
4//! a page mid-write, and JSON export drops the operation history. Backups therefore use SQLite's
5//! online backup API, which copies every page of one read snapshot while writers continue.
6//! A backup is sealed as an archive of its source's lineage; a restore is sealed as a new lineage.
7
8use super::{
9    SqliteStore,
10    integrity::{check_history_blocking, integrity_check_blocking},
11    lineage::{self, Seal},
12    load_blocking,
13    replay::replay_blocking,
14    runs::{self, RunStore, RunStoreReport},
15    snapshot::genesis_blocking,
16};
17use crate::{
18    StoreError,
19    error::database_error,
20    schema::{self, Layout},
21};
22use dpm_model::{LineageId, WorkspaceId};
23use files::{
24    ReadMode, canonical_blocking, create_target_blocking, discard_blocking, fingerprint_blocking,
25    open_read_only_blocking, read_mode_blocking, target_path_blocking,
26};
27use rusqlite::{
28    Connection,
29    backup::{Backup, StepResult},
30};
31use serde::Serialize;
32use std::{
33    path::{Path, PathBuf},
34    time::Duration,
35};
36
37pub(super) mod files;
38
39const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
40const BUSY_RETRY_DELAY: Duration = Duration::from_millis(100);
41const BUSY_RETRIES: u32 = 50;
42
43/// Outcome of a full read-only verification of one store.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct IntegrityReport {
46    /// Verified database file as a canonical absolute path.
47    pub path: PathBuf,
48    /// Version stored in the header.
49    pub schema_version: i64,
50    /// Workspace identity a device binding or project locator must match.
51    pub workspace_id: WorkspaceId,
52    /// Workspace display name.
53    pub workspace_name: String,
54    /// Writable history the file continues; a restore reports a new one.
55    pub lineage_id: LineageId,
56    /// Whether the file is a backup archive that only a restore makes writable again.
57    pub archived: bool,
58    /// Snapshot revision, equal to the last operation's resulting revision.
59    pub revision: u64,
60    /// Number of committed operations in the history.
61    pub operation_count: u64,
62    /// Revision of the genesis plan recorded at initialization, which the history starts from.
63    pub genesis_revision: u64,
64    /// The run store beside the project store, verified with it; absent when none was found
65    /// beside it, which says nothing about whether runs were ever recorded. A project store file
66    /// alone never contains runs.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub runs: Option<RunStoreReport>,
69}
70
71impl SqliteStore {
72    /// Copy the committed snapshot, full operation history and schema version into a new file,
73    /// then verify the copy; an existing file is never overwritten.
74    ///
75    /// When the store has a run store beside it, that is copied too, to `<backup>.runs`, and the
76    /// report says so; the project backup file alone never contains runs.
77    pub fn backup_blocking(&self, to: &Path) -> Result<IntegrityReport, StoreError> {
78        let to = target_path_blocking(to)?;
79        let runs_to = RunStore::sidecar_path(&to);
80        refuse_existing_blocking(&runs_to)?;
81        let runs_from = RunStore::sidecar_path(&self.path);
82        let has_runs = nonempty_blocking(&runs_from)?;
83        create_target_blocking(&to)?;
84        if has_runs {
85            runs::recovery::create_target(&runs_to).inspect_err(|_| discard_blocking(&to))?;
86        }
87        let result = self
88            .copy_runs_blocking(&runs_from, &runs_to, has_runs)
89            .and_then(|()| {
90                // A backup is one self-contained file, so it carries no WAL side files.
91                copy_blocking(&self.connection, &to, "delete", |target| {
92                    lineage::seal_blocking(target, &to, Seal::Archive)
93                })
94            })
95            .and_then(|()| verify_store_blocking(&to));
96        discard_all_on_error_blocking(&to, has_runs, result)
97    }
98
99    /// Copy the run store beside this store, if there is one, before the project store is copied.
100    ///
101    /// The two copies cannot share one snapshot. Taking the run store's first keeps the pair
102    /// consistent whatever commits in between: a run store only links operations the project
103    /// store had already committed, so a later project copy holds every operation the earlier run
104    /// copy names, and only unlinked later operations differ. The reverse order could attribute an
105    /// operation the project copy lacks, which verification refuses.
106    fn copy_runs_blocking(&self, from: &Path, to: &Path, has_runs: bool) -> Result<(), StoreError> {
107        if !has_runs {
108            return Ok(());
109        }
110        let workspace = self
111            .load_blocking()?
112            .ok_or_else(|| StoreError::NotInitialized(self.path.clone()))?
113            .workspace
114            .id;
115        RunStore::open_existing_blocking(from, workspace)
116            .map_err(run_store_error(from))?
117            .ok_or_else(|| missing_runs(from))?
118            .backup_to_blocking(to)
119            .map_err(run_store_error(to))
120            .map(drop)
121    }
122}
123
124/// Verify a backup, copy it into a new writable store file in WAL mode with a new lineage, and
125/// verify the result; the source keeps its own lineage.
126///
127/// Live state is never overwritten: the target must not exist, and a failed restore removes it.
128pub fn restore_store_blocking(from: &Path, to: &Path) -> Result<IntegrityReport, StoreError> {
129    let from = canonical_blocking(from)?;
130    let to = target_path_blocking(to)?;
131    let source_report = verify_store_blocking(&from)?;
132    if !source_report.archived {
133        return Err(crate::LineageError::NotAnArchive { path: from }.into());
134    }
135    let runs_from = RunStore::sidecar_path(&from);
136    let runs_to = RunStore::sidecar_path(&to);
137    refuse_existing_blocking(&runs_to)?;
138    let has_runs = nonempty_blocking(&runs_from)?;
139    let mode = read_mode_blocking(&from)?;
140    let before = fingerprint_blocking(&from)?;
141    let source = open_read_only_blocking(&from, mode)?;
142    create_target_blocking(&to)?;
143    if has_runs {
144        runs::recovery::create_target(&runs_to).inspect_err(|_| discard_blocking(&to))?;
145    }
146    let fork = LineageId::new();
147    let result = copy_blocking(&source, &to, "wal", |target| {
148        lineage::seal_blocking(target, &to, Seal::Fork(fork))
149    })
150    .and_then(|()| {
151        // An immutable read cannot see a writer that started meanwhile, so the copy is only
152        // trusted if the source provably did not change.
153        if mode == ReadMode::Immutable && fingerprint_blocking(&from)? != before {
154            return Err(StoreError::BackupIncomplete {
155                path: to.clone(),
156                state: format!("{} changed during the restore", from.display()),
157            });
158        }
159        Ok(())
160    })
161    .and_then(|()| {
162        if has_runs {
163            runs::recovery::restore_blocking(
164                &runs_from,
165                &runs_to,
166                source_report.workspace_id,
167                fork,
168                source_report.revision,
169            )
170            .map_err(run_store_error(&runs_to))?;
171        }
172        verify_store_blocking(&to)
173    });
174    discard_all_on_error_blocking(&to, has_runs, result)
175}
176
177/// Check pages, exact layout, snapshot and history of a store in one read transaction, then replay
178/// the history from the genesis plan and compare it with the snapshot, without writing to the store
179/// or creating any file next to it. A divergence is reported, never repaired.
180///
181/// A run store beside the file is verified with it, and the two must be a consistent pair: the run
182/// store bound to this workspace and lineage, every run observing a revision this history holds,
183/// and every operation link naming an operation in this history that passes the link rules. The
184/// run store is read first and the project store after it, so a live pair does not fail because an
185/// operation and its link arrived in between; a run store copied later than its project store does.
186pub fn verify_store_blocking(path: &Path) -> Result<IntegrityReport, StoreError> {
187    let path = canonical_blocking(path)?;
188    let runs_path = RunStore::sidecar_path(&path);
189    if !nonempty_blocking(&runs_path)? {
190        return verify_project_blocking(&path, None);
191    }
192    let (runs, pair) = runs::recovery::verify_with_facts_blocking(&runs_path)
193        .map_err(run_store_error(&runs_path))?;
194    let report = verify_project_blocking(&path, Some(&pair))?;
195    Ok(IntegrityReport {
196        runs: Some(runs),
197        ..report
198    })
199}
200
201/// The project store, with its run store pair when one is given: pages, layout, snapshot and
202/// history, then the replay.
203fn verify_project_blocking(
204    path: &Path,
205    pair: Option<&runs::pairing::PairFacts>,
206) -> Result<IntegrityReport, StoreError> {
207    let path = canonical_blocking(path)?;
208    if read_mode_blocking(&path)? == ReadMode::Immutable {
209        let before = fingerprint_blocking(&path)?;
210        let result = verify_with_blocking(&path, ReadMode::Immutable, pair);
211        // A writer that started meanwhile may have changed pages under the unlocked read.
212        if fingerprint_blocking(&path)? == before {
213            return result;
214        }
215    }
216    verify_with_blocking(&path, ReadMode::Shared, pair)
217}
218
219fn verify_with_blocking(
220    path: &Path,
221    mode: ReadMode,
222    pair: Option<&runs::pairing::PairFacts>,
223) -> Result<IntegrityReport, StoreError> {
224    let mut connection = open_read_only_blocking(path, mode)?;
225    // One read transaction keeps a concurrent writer from separating snapshot and history.
226    let transaction = connection
227        .transaction()
228        .map_err(database_error(path, "begin verification"))?;
229    let schema_version = schema::stored_version_blocking(&transaction, path)?;
230    let layout = schema::check_blocking(&transaction, path)?;
231    if layout == Layout::Empty {
232        return Err(StoreError::UnrecognizedSchema {
233            path: path.to_path_buf(),
234            detail: "no DPM tables".into(),
235        });
236    }
237    integrity_check_blocking(&transaction, path)?;
238    let plan = load_blocking(&transaction, path)?
239        .ok_or_else(|| StoreError::NotInitialized(path.to_path_buf()))?;
240    let genesis =
241        genesis_blocking(&transaction, path)?.ok_or_else(|| StoreError::MissingGenesis {
242            path: path.to_path_buf(),
243        })?;
244    let genesis_revision = genesis.revision;
245    let store_lineage = lineage::read_blocking(&transaction, path)?;
246    let history = check_history_blocking(
247        &transaction,
248        path,
249        genesis.workspace.id,
250        (genesis_revision, plan.revision),
251    )?;
252    replay_blocking(&transaction, path, genesis, &plan)?;
253    if let Some(pair) = pair {
254        let identity = runs::pairing::ProjectIdentity {
255            workspace: plan.workspace.id,
256            lineage: store_lineage.lineage_id,
257            snapshot_revision: plan.revision,
258            genesis_revision,
259        };
260        runs::pairing::cross_check_blocking(&transaction, path, &identity, pair)
261            .map_err(run_store_error(&RunStore::sidecar_path(path)))?;
262    }
263    Ok(IntegrityReport {
264        path: path.to_path_buf(),
265        schema_version,
266        workspace_id: plan.workspace.id,
267        workspace_name: plan.workspace.name,
268        lineage_id: store_lineage.lineage_id,
269        archived: store_lineage.archived,
270        revision: plan.revision,
271        operation_count: history.count,
272        genesis_revision,
273        runs: None,
274    })
275}
276
277/// Copy every page of `source` into the new file `to`, let `seal` mark the copy before it is
278/// closed, and set its journal mode.
279pub(in crate::sqlite) fn copy_blocking(
280    source: &Connection,
281    to: &Path,
282    journal_mode: &str,
283    seal: impl FnOnce(&Connection) -> Result<(), StoreError>,
284) -> Result<(), StoreError> {
285    let mut target = Connection::open(to).map_err(database_error(to, "open backup target"))?;
286    target
287        .busy_timeout(BUSY_TIMEOUT)
288        .map_err(database_error(to, "set busy timeout"))?;
289    {
290        let backup =
291            Backup::new(source, &mut target).map_err(database_error(to, "start backup"))?;
292        let mut retries = 0;
293        loop {
294            // Copying all pages in one step reads a single snapshot of the source.
295            match backup.step(-1).map_err(database_error(to, "copy pages"))? {
296                StepResult::Done => break,
297                StepResult::Busy | StepResult::Locked if retries < BUSY_RETRIES => {
298                    retries += 1;
299                    std::thread::sleep(BUSY_RETRY_DELAY);
300                }
301                state => {
302                    return Err(StoreError::BackupIncomplete {
303                        path: to.to_path_buf(),
304                        state: format!("{state:?}"),
305                    });
306                }
307            }
308        }
309    }
310    // Sealed before the copy is verified or closed, so no unsealed copy is ever reported.
311    seal(&target)?;
312    let mode: String = target
313        .query_row(
314            &format!("PRAGMA journal_mode = {journal_mode}"),
315            [],
316            |row| row.get(0),
317        )
318        .map_err(database_error(to, "set journal mode"))?;
319    if !mode.eq_ignore_ascii_case(journal_mode) {
320        return Err(StoreError::BackupIncomplete {
321            path: to.to_path_buf(),
322            state: format!("journal mode {mode} instead of {journal_mode}"),
323        });
324    }
325    target
326        .close()
327        .map_err(|(_, source)| database_error(to, "close backup target")(source))
328}
329
330/// Remove the targets this command created, the run store's only if it was one of them.
331fn discard_all_on_error_blocking(
332    to: &Path,
333    with_runs: bool,
334    result: Result<IntegrityReport, StoreError>,
335) -> Result<IntegrityReport, StoreError> {
336    if result.is_err() {
337        discard_blocking(to);
338        if with_runs {
339            runs::recovery::discard(&RunStore::sidecar_path(to));
340        }
341    }
342    result
343}
344
345/// Whether a run store file exists beside a project store; an empty file left by an interrupted
346/// creation holds no run.
347fn nonempty_blocking(path: &Path) -> Result<bool, StoreError> {
348    match std::fs::metadata(path) {
349        Ok(metadata) => Ok(metadata.len() > 0),
350        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false),
351        Err(source) => Err(StoreError::Io {
352            action: "inspect",
353            path: path.to_path_buf(),
354            source,
355        }),
356    }
357}
358
359/// A run store beside a new target would be adopted by it, so a target never starts beside one.
360fn refuse_existing_blocking(path: &Path) -> Result<(), StoreError> {
361    let exists = path.try_exists().map_err(|source| StoreError::Io {
362        action: "inspect",
363        path: path.to_path_buf(),
364        source,
365    })?;
366    if exists {
367        return Err(StoreError::TargetExists {
368            path: path.to_path_buf(),
369        });
370    }
371    Ok(())
372}
373
374/// A failure of the run store beside a project store. Database, layout and I/O failures keep their
375/// own typed errors; a finding about the run store's contents is reported as an integrity failure
376/// of that file, which names it, so the project store itself is never blamed.
377fn run_store_error(path: &Path) -> impl FnOnce(crate::RunStoreError) -> StoreError {
378    let path = path.to_path_buf();
379    move |source| match source {
380        crate::RunStoreError::Store(error) => error,
381        other => StoreError::IntegrityCheckFailed {
382            path,
383            problems: vec![other.to_string()],
384        },
385    }
386}
387
388fn missing_runs(path: &Path) -> StoreError {
389    run_store_error(path)(crate::RunStoreError::Corrupt {
390        path: path.to_path_buf(),
391        detail: "the run store disappeared while it was being backed up".into(),
392    })
393}
394
395#[cfg(test)]
396mod tests;