Skip to main content

sqlite_graphrag/commands/
backup.rs

1//! Handler for the `backup` CLI subcommand.
2//!
3//! Uses the SQLite Online Backup API (via rusqlite) to produce a consistent
4//! point-in-time copy of the database file even while the database is in use.
5
6use crate::errors::AppError;
7use crate::output;
8use crate::paths::AppPaths;
9use crate::storage::connection::open_ro;
10use serde::Serialize;
11use std::path::PathBuf;
12use tempfile::NamedTempFile;
13
14/// Default number of pages copied per backup step.
15///
16/// G38: the previous default of 100 pages with 50 ms sleep between steps
17/// was the dominant cost on large databases (4.3 GB took ~9 minutes purely
18/// on sleep). 1000 pages × 5 ms is ~25× faster on a 4.3 GB database while
19/// remaining gentle on SSD I/O. Override with `--backup-step-size`.
20const DEFAULT_BACKUP_STEP_PAGES: usize = 1000;
21const DEFAULT_BACKUP_STEP_SLEEP_MS: u64 = 5;
22
23#[derive(clap::Args)]
24#[command(after_long_help = "EXAMPLES:\n  \
25    # Back up the default database to a specific path\n  \
26    sqlite-graphrag backup --output /backup/graphrag-$(date +%F).sqlite\n\n  \
27    # Back up a custom source database\n  \
28    sqlite-graphrag backup --db /data/graphrag.sqlite --output /backup/snapshot.sqlite\n\n  \
29    # Tuned for a 4.3 GB database on local SSD\n  \
30    sqlite-graphrag backup --output /backup/snap.sqlite --backup-step-size 2000 --backup-step-sleep-ms 2\n\n  \
31    # Maximum throughput (no sleep between steps — risks I/O contention)\n  \
32    sqlite-graphrag backup --output /backup/snap.sqlite --backup-no-sleep\n\n  \
33NOTES:\n  \
34    Uses the SQLite Online Backup API: safe to run while the database is in use.\n  \
35    The destination is written atomically via tempfile-rename in the same directory.\n  \
36    If the process is interrupted, the previous file (if any) remains intact.\n  \
37    On Unix the destination is chmod 0600 after the backup completes.")]
38/// Backup args.
39pub struct BackupArgs {
40    /// Destination path for the backup file. Required.
41    #[arg(long, value_name = "PATH")]
42    pub output: PathBuf,
43    /// Emit machine-readable JSON on stdout.
44    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
45    pub json: bool,
46    /// Path to the SQLite database file.
47    #[arg(long)]
48    pub db: Option<String>,
49    /// Number of pages copied per backup step. Default: 1000 (was 100 before v1.0.69).
50    /// Larger values finish faster on local SSD but may contend on NFS.
51    #[arg(long, value_name = "PAGES", default_value_t = DEFAULT_BACKUP_STEP_PAGES)]
52    pub backup_step_size: usize,
53    /// Sleep duration in milliseconds between backup steps. Default: 5 (was 50 before v1.0.69).
54    /// Ignored when --backup-no-sleep is set.
55    #[arg(long, value_name = "MILLIS", default_value_t = DEFAULT_BACKUP_STEP_SLEEP_MS)]
56    pub backup_step_sleep_ms: u64,
57    /// Disable the inter-step sleep entirely. Maximum throughput, but risks
58    /// starving concurrent I/O on shared storage.
59    #[arg(long, default_value_t = false)]
60    pub backup_no_sleep: bool,
61    /// Emit a progress line to stderr every N pages (G38 observability).
62    /// Default: 100 (every 100 pages = ~400 KB). Set to 0 to disable.
63    #[arg(long, value_name = "PAGES", default_value_t = 100)]
64    pub backup_progress: i32,
65}
66
67#[derive(Serialize)]
68struct BackupResponse {
69    action: String,
70    source: String,
71    destination: String,
72    size_bytes: u64,
73    elapsed_ms: u64,
74    pages_copied: Option<i64>,
75    step_size: usize,
76}
77
78/// Run.
79pub fn run(args: BackupArgs) -> Result<(), AppError> {
80    let start = std::time::Instant::now();
81    let paths = AppPaths::resolve(args.db.as_deref())?;
82
83    crate::storage::connection::ensure_db_ready(&paths)?;
84
85    // Validate: destination must differ from source.
86    if args.output == paths.db {
87        return Err(AppError::Validation(
88            "destination path must differ from the source database path".to_string(),
89        ));
90    }
91
92    // Create parent directories if necessary.
93    let parent = args.output.parent().unwrap_or(std::path::Path::new("."));
94    if !parent.as_os_str().is_empty() {
95        std::fs::create_dir_all(parent)?;
96    }
97
98    // Atomic write: backup to tempfile in the SAME directory, then rename.
99    let temp = NamedTempFile::new_in(parent).map_err(AppError::Io)?;
100    let temp_path = temp.path().to_path_buf();
101
102    let src_conn = open_ro(&paths.db)?;
103    let mut dst_conn = rusqlite::Connection::open(&temp_path)?;
104
105    let step_size = args.backup_step_size.max(1);
106    let sleep = if args.backup_no_sleep {
107        std::time::Duration::ZERO
108    } else {
109        std::time::Duration::from_millis(args.backup_step_sleep_ms)
110    };
111
112    let pages_copied: Option<i64> = {
113        let backup = rusqlite::backup::Backup::new(&src_conn, &mut dst_conn)?;
114        // G38: drive the backup in a manual step() loop so we can emit
115        // per-step progress events without depending on a Copy closure
116        // (which the rusqlite Progress callback requires). The loop
117        // mirrors run_to_completion but exposes progress for observability.
118        let step_size_i32: i32 = step_size.try_into().unwrap_or(1000);
119        let progress_every = args.backup_progress.max(1);
120        let mut last_emit_pages: i32 = -1;
121        loop {
122            use rusqlite::backup::StepResult;
123            match backup.step(step_size_i32) {
124                Ok(StepResult::More) => {
125                    // step returned More: backup still in progress.
126                    if progress_every > 0 {
127                        let p = backup.progress();
128                        let copied = p.pagecount - p.remaining;
129                        if copied > 0 && copied - last_emit_pages >= progress_every {
130                            last_emit_pages = copied;
131                            let percent = if p.pagecount > 0 {
132                                (copied as f64 / p.pagecount as f64) * 100.0
133                            } else {
134                                100.0
135                            };
136                            output::emit_progress(&format!(
137                                "backup progress: pages_copied={copied} total_pages={pc} percent={pct:.2}",
138                                pc = p.pagecount,
139                                pct = percent
140                            ));
141                        }
142                    }
143                    if !sleep.is_zero() {
144                        std::thread::sleep(sleep);
145                    }
146                }
147                Ok(StepResult::Done) => break, // backup complete
148                Ok(_) => {
149                    // Transient (Busy / Locked on newer rusqlite or any
150                    // future non-exhaustive variant): retry after backoff.
151                    std::thread::sleep(std::time::Duration::from_millis(
152                        crate::constants::BACKUP_BUSY_RETRY_DELAY_MS,
153                    ));
154                }
155                Err(e) => return Err(AppError::Database(e)),
156            }
157        }
158        // `Progress { remaining, pagecount }` (see rusqlite::backup::Progress):
159        // pages already copied = pagecount - remaining.
160        let progress = backup.progress();
161        let copied = (progress.pagecount - progress.remaining).max(0);
162        Some(copied as i64)
163    };
164    drop(dst_conn);
165
166    temp.persist(&args.output)
167        .map_err(|e| AppError::Io(e.error))?;
168
169    // Apply 0600 permissions on Unix to prevent leakage in shared directories.
170    #[cfg(unix)]
171    {
172        use std::os::unix::fs::PermissionsExt;
173        if let Ok(meta) = std::fs::metadata(&args.output) {
174            let mut perms = meta.permissions();
175            perms.set_mode(0o600);
176            if let Err(e) = std::fs::set_permissions(&args.output, perms) {
177                tracing::warn!(target: "backup",
178                    path = %args.output.display(),
179                    error = %e,
180                    "failed to set 0600 permissions on backup file"
181                );
182            }
183        }
184    }
185    #[cfg(windows)]
186    {
187        tracing::debug!(target: "backup",
188            path = %args.output.display(),
189            "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
190        );
191    }
192
193    let size_bytes = std::fs::metadata(&args.output)
194        .map(|m| m.len())
195        .unwrap_or(0);
196
197    output::emit_json(&BackupResponse {
198        action: "backed_up".to_string(),
199        source: paths.db.display().to_string(),
200        destination: args.output.display().to_string(),
201        size_bytes,
202        elapsed_ms: start.elapsed().as_millis() as u64,
203        pages_copied,
204        step_size,
205    })?;
206
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn backup_response_serializes_all_fields() {
216        let resp = BackupResponse {
217            action: "backed_up".to_string(),
218            source: "/data/graphrag.sqlite".to_string(),
219            destination: "/backup/snapshot.sqlite".to_string(),
220            size_bytes: 32768,
221            elapsed_ms: 42,
222            pages_copied: Some(512),
223            step_size: 1000,
224        };
225        let json = serde_json::to_value(&resp).expect("serialization failed");
226        assert_eq!(json["action"], "backed_up");
227        assert_eq!(json["source"], "/data/graphrag.sqlite");
228        assert_eq!(json["destination"], "/backup/snapshot.sqlite");
229        assert_eq!(json["size_bytes"], 32768u64);
230        assert_eq!(json["elapsed_ms"], 42u64);
231        assert_eq!(json["step_size"], 1000usize);
232        assert_eq!(json["pages_copied"], 512i64);
233    }
234
235    #[test]
236    fn backup_response_action_is_backed_up() {
237        let resp = BackupResponse {
238            action: "backed_up".to_string(),
239            source: "/a.sqlite".to_string(),
240            destination: "/b.sqlite".to_string(),
241            size_bytes: 0,
242            elapsed_ms: 0,
243            pages_copied: None,
244            step_size: 1000,
245        };
246        let json = serde_json::to_value(&resp).expect("serialization failed");
247        assert_eq!(
248            json["action"], "backed_up",
249            "action must always be 'backed_up'"
250        );
251    }
252
253    #[test]
254    fn backup_rejects_destination_equal_to_source() {
255        // Simulate the guard without a real DB.
256        let src = PathBuf::from("/tmp/graphrag.sqlite");
257        let dst = PathBuf::from("/tmp/graphrag.sqlite");
258        let result: Result<(), AppError> = if dst == src {
259            Err(AppError::Validation(
260                "destination path must differ from the source database path".to_string(),
261            ))
262        } else {
263            Ok(())
264        };
265        assert!(
266            result.is_err(),
267            "must reject identical source and destination"
268        );
269        if let Err(AppError::Validation(msg)) = result {
270            assert!(msg.contains("destination path must differ"));
271        }
272    }
273
274    #[test]
275    fn backup_response_size_bytes_zero_is_valid() {
276        let resp = BackupResponse {
277            action: "backed_up".to_string(),
278            source: "/a.sqlite".to_string(),
279            destination: "/b.sqlite".to_string(),
280            size_bytes: 0,
281            elapsed_ms: 1,
282            pages_copied: Some(0),
283            step_size: 1000,
284        };
285        let json = serde_json::to_value(&resp).expect("serialization failed");
286        assert!(json["size_bytes"].as_u64().is_some());
287    }
288
289    #[test]
290    fn backup_default_step_size_is_one_thousand() {
291        // G38: the historical default of 100 pages caused backups of 4.3 GB
292        // databases to take 9 minutes solely on sleep. The new default of
293        // 1000 pages with 5 ms sleep gives ~25x speedup.
294        assert_eq!(DEFAULT_BACKUP_STEP_PAGES, 1000);
295        assert_eq!(DEFAULT_BACKUP_STEP_SLEEP_MS, 5);
296    }
297}