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(50));
152                }
153                Err(e) => return Err(AppError::Database(e)),
154            }
155        }
156        // `Progress { remaining, pagecount }` (see rusqlite::backup::Progress):
157        // pages already copied = pagecount - remaining.
158        let progress = backup.progress();
159        let copied = (progress.pagecount - progress.remaining).max(0);
160        Some(copied as i64)
161    };
162    drop(dst_conn);
163
164    temp.persist(&args.output)
165        .map_err(|e| AppError::Io(e.error))?;
166
167    // Apply 0600 permissions on Unix to prevent leakage in shared directories.
168    #[cfg(unix)]
169    {
170        use std::os::unix::fs::PermissionsExt;
171        if let Ok(meta) = std::fs::metadata(&args.output) {
172            let mut perms = meta.permissions();
173            perms.set_mode(0o600);
174            if let Err(e) = std::fs::set_permissions(&args.output, perms) {
175                tracing::warn!(target: "backup",
176                    path = %args.output.display(),
177                    error = %e,
178                    "failed to set 0600 permissions on backup file"
179                );
180            }
181        }
182    }
183    #[cfg(windows)]
184    {
185        tracing::debug!(target: "backup",
186            path = %args.output.display(),
187            "skipping Unix mode 0o600 on Windows; NTFS DACL default is private-to-user"
188        );
189    }
190
191    let size_bytes = std::fs::metadata(&args.output)
192        .map(|m| m.len())
193        .unwrap_or(0);
194
195    output::emit_json(&BackupResponse {
196        action: "backed_up".to_string(),
197        source: paths.db.display().to_string(),
198        destination: args.output.display().to_string(),
199        size_bytes,
200        elapsed_ms: start.elapsed().as_millis() as u64,
201        pages_copied,
202        step_size,
203    })?;
204
205    Ok(())
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn backup_response_serializes_all_fields() {
214        let resp = BackupResponse {
215            action: "backed_up".to_string(),
216            source: "/data/graphrag.sqlite".to_string(),
217            destination: "/backup/snapshot.sqlite".to_string(),
218            size_bytes: 32768,
219            elapsed_ms: 42,
220            pages_copied: Some(512),
221            step_size: 1000,
222        };
223        let json = serde_json::to_value(&resp).expect("serialization failed");
224        assert_eq!(json["action"], "backed_up");
225        assert_eq!(json["source"], "/data/graphrag.sqlite");
226        assert_eq!(json["destination"], "/backup/snapshot.sqlite");
227        assert_eq!(json["size_bytes"], 32768u64);
228        assert_eq!(json["elapsed_ms"], 42u64);
229        assert_eq!(json["step_size"], 1000usize);
230        assert_eq!(json["pages_copied"], 512i64);
231    }
232
233    #[test]
234    fn backup_response_action_is_backed_up() {
235        let resp = BackupResponse {
236            action: "backed_up".to_string(),
237            source: "/a.sqlite".to_string(),
238            destination: "/b.sqlite".to_string(),
239            size_bytes: 0,
240            elapsed_ms: 0,
241            pages_copied: None,
242            step_size: 1000,
243        };
244        let json = serde_json::to_value(&resp).expect("serialization failed");
245        assert_eq!(
246            json["action"], "backed_up",
247            "action must always be 'backed_up'"
248        );
249    }
250
251    #[test]
252    fn backup_rejects_destination_equal_to_source() {
253        // Simulate the guard without a real DB.
254        let src = PathBuf::from("/tmp/graphrag.sqlite");
255        let dst = PathBuf::from("/tmp/graphrag.sqlite");
256        let result: Result<(), AppError> = if dst == src {
257            Err(AppError::Validation(
258                "destination path must differ from the source database path".to_string(),
259            ))
260        } else {
261            Ok(())
262        };
263        assert!(
264            result.is_err(),
265            "must reject identical source and destination"
266        );
267        if let Err(AppError::Validation(msg)) = result {
268            assert!(msg.contains("destination path must differ"));
269        }
270    }
271
272    #[test]
273    fn backup_response_size_bytes_zero_is_valid() {
274        let resp = BackupResponse {
275            action: "backed_up".to_string(),
276            source: "/a.sqlite".to_string(),
277            destination: "/b.sqlite".to_string(),
278            size_bytes: 0,
279            elapsed_ms: 1,
280            pages_copied: Some(0),
281            step_size: 1000,
282        };
283        let json = serde_json::to_value(&resp).expect("serialization failed");
284        assert!(json["size_bytes"].as_u64().is_some());
285    }
286
287    #[test]
288    fn backup_default_step_size_is_one_thousand() {
289        // G38: the historical default of 100 pages caused backups of 4.3 GB
290        // databases to take 9 minutes solely on sleep. The new default of
291        // 1000 pages with 5 ms sleep gives ~25x speedup.
292        assert_eq!(DEFAULT_BACKUP_STEP_PAGES, 1000);
293        assert_eq!(DEFAULT_BACKUP_STEP_SLEEP_MS, 5);
294    }
295}