sqlite_graphrag/commands/
backup.rs1use 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
14const 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.")]
38pub struct BackupArgs {
40 #[arg(long, value_name = "PATH")]
42 pub output: PathBuf,
43 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
45 pub json: bool,
46 #[arg(long)]
48 pub db: Option<String>,
49 #[arg(long, value_name = "PAGES", default_value_t = DEFAULT_BACKUP_STEP_PAGES)]
52 pub backup_step_size: usize,
53 #[arg(long, value_name = "MILLIS", default_value_t = DEFAULT_BACKUP_STEP_SLEEP_MS)]
56 pub backup_step_sleep_ms: u64,
57 #[arg(long, default_value_t = false)]
60 pub backup_no_sleep: bool,
61 #[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
78pub 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 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 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 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 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 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, Ok(_) => {
149 std::thread::sleep(std::time::Duration::from_millis(50));
152 }
153 Err(e) => return Err(AppError::Database(e)),
154 }
155 }
156 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 #[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 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 assert_eq!(DEFAULT_BACKUP_STEP_PAGES, 1000);
293 assert_eq!(DEFAULT_BACKUP_STEP_SLEEP_MS, 5);
294 }
295}