on master help sign in
Add `knot dump` / `knot restore`: binary db snapshot for the knot CLI
38697fciko
crates/knot-compiler/src/main.rs+40
@@ -354,6 +354,40 @@ fn main() {
354 }
354 }
355 cmd_lock(&args[2]);
355 cmd_lock(&args[2]);
356 }
356 }
357+ "dump" => {
358+ if args.len() < 4 {
359+ eprintln!("Error: missing arguments");
360+ eprintln!("Usage: knot dump <db> <out>");
361+ process::exit(1);
362+ }
363+ match knot_runtime::db_dump::dump_db(
364+ std::path::Path::new(&args[2]),
365+ std::path::Path::new(&args[3]),
366+ ) {
367+ Ok(()) => println!("Dumped {} -> {}", args[2], args[3]),
368+ Err(e) => {
369+ eprintln!("Error: {}", e);
370+ process::exit(1);
371+ }
372+ }
373+ }
374+ "restore" => {
375+ if args.len() < 4 {
376+ eprintln!("Error: missing arguments");
377+ eprintln!("Usage: knot restore <dump> <db>");
378+ process::exit(1);
379+ }
380+ match knot_runtime::db_dump::restore_db(
381+ std::path::Path::new(&args[2]),
382+ std::path::Path::new(&args[3]),
383+ ) {
384+ Ok(()) => println!("Restored {} -> {}", args[2], args[3]),
385+ Err(e) => {
386+ eprintln!("Error: {}", e);
387+ process::exit(1);
388+ }
389+ }
390+ }
357 "--help" | "-h" | "help" => print_usage(),
391 "--help" | "-h" | "help" => print_usage(),
358 other => {
392 other => {
359 eprintln!("Unknown command: {}", other);
393 eprintln!("Unknown command: {}", other);
@@ -379,6 +413,12 @@ fn print_usage() {
379 eprintln!(
413 eprintln!(
380 " knot lock <file.knot> Commit pending schema migrations into <file>.schema.lock"
414 " knot lock <file.knot> Commit pending schema migrations into <file>.schema.lock"
381 );
415 );
416+ eprintln!(
417+ " knot dump <db> <out> Copy a (live) database to a single binary file"
418+ );
419+ eprintln!(
420+ " knot restore <dump> <db> Restore a dumped file over a database (offline)"
421+ );
382 eprintln!(" knot help Show this help message");
422 eprintln!(" knot help Show this help message");
383}
423}
384
424
crates/knot-runtime/src/db_dump.rsnew file+143
@@ -0,0 +1,143 @@
1+//! Binary dump/restore of a knot database as a single portable file.
2+//!
3+//! `dump` uses SQLite's `VACUUM INTO`, which produces a consistent,
4+//! self-contained copy of a *live* database (WAL content is checkpointed into
5+//! the copy; no `-wal`/`-shm` sidecars). The result is itself a valid SQLite
6+//! database — so `restore` is just copying that file over the target, atomically
7+//! (write to a temp sibling, then rename) so an interrupted restore never
8+//! leaves a half-written db.
9+
10+use rusqlite::Connection;
11+use std::path::Path;
12+
13+/// Copy the database at `src` to a single binary file at `dest`, consistent
14+/// even while `src` is live. Safe to call on a database another process is
15+/// writing: `VACUUM INTO` runs under the source's own locking.
16+///
17+/// `dest` must not already exist (SQLite refuses to overwrite), so a stale
18+/// copy is removed first only if it's a file we're replacing wholesale.
19+pub fn dump_db(src: &Path, dest: &Path) -> Result<(), String> {
20+ if !src.exists() {
21+ return Err(format!("no database at {}", src.display()));
22+ }
23+ // SQLite requires the VACUUM INTO target to not exist. Remove a previous
24+ // dump so re-dumping to the same path works.
25+ if dest.exists() {
26+ std::fs::remove_file(dest)
27+ .map_err(|e| format!("cannot replace existing dump at {}: {}", dest.display(), e))?;
28+ }
29+ let conn = Connection::open(src).map_err(|e| format!("cannot open {}: {}", src.display(), e))?;
30+ let dest_str = dest
31+ .to_str()
32+ .ok_or_else(|| format!("destination path is not valid UTF-8: {}", dest.display()))?;
33+ // Escape single quotes for the SQL string literal.
34+ let escaped = dest_str.replace('\'', "''");
35+ conn.execute_batch(&format!("VACUUM INTO '{}';", escaped))
36+ .map_err(|e| format!("dump failed: {}", e))
37+}
38+
39+/// Restore a database: copy a dump file (produced by [`dump_db`]) over `dest`
40+/// atomically. The dump is a complete SQLite database, so restore is a file
41+/// copy — done via a temp sibling + rename so a crash mid-restore leaves the
42+/// old `dest` intact rather than a truncated one.
43+///
44+/// The target must not be in use: this replaces the file, which a live writer
45+/// would not expect. (Dump is live-safe; restore is an offline operation.)
46+pub fn restore_db(dump: &Path, dest: &Path) -> Result<(), String> {
47+ if !dump.exists() {
48+ return Err(format!("no dump file at {}", dump.display()));
49+ }
50+ // Sanity-check the dump is a SQLite db before overwriting anything.
51+ Connection::open(dump)
52+ .and_then(|c| c.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())))
53+ .map_err(|e| format!("{} is not a valid database: {}", dump.display(), e))?;
54+
55+ let parent = dest
56+ .parent()
57+ .ok_or_else(|| format!("destination has no parent directory: {}", dest.display()))?;
58+ let tmp = parent.join(format!(
59+ ".{}.knot-restore-tmp",
60+ dest.file_name()
61+ .and_then(|n| n.to_str())
62+ .unwrap_or("db")
63+ ));
64+ std::fs::copy(dump, &tmp).map_err(|e| format!("failed to stage restore: {}", e))?;
65+ std::fs::rename(&tmp, dest).map_err(|e| {
66+ let _ = std::fs::remove_file(&tmp);
67+ format!("failed to move restored db into place: {}", e)
68+ })
69+}
70+
71+#[cfg(test)]
72+mod tests {
73+ use super::*;
74+
75+ fn fresh(dir: &str, name: &str) -> std::path::PathBuf {
76+ let d = std::env::temp_dir().join(format!("knot_dbdump_{}_{}", dir, std::process::id()));
77+ std::fs::create_dir_all(&d).unwrap();
78+ d.join(name)
79+ }
80+
81+ /// Build a small db with a known row.
82+ fn make_db(path: &Path) {
83+ let c = Connection::open(path).unwrap();
84+ c.execute_batch("CREATE TABLE t (name TEXT, n INTEGER); INSERT INTO t VALUES ('a', 1), ('b', 2);").unwrap();
85+ }
86+
87+ fn row_count(path: &Path) -> i64 {
88+ Connection::open(path)
89+ .unwrap()
90+ .query_row("SELECT count(*) FROM t", [], |r| r.get(0))
91+ .unwrap()
92+ }
93+
94+ #[test]
95+ fn dump_then_restore_roundtrips_data() {
96+ let src = fresh("rt", "src.db");
97+ let dump = fresh("rt", "out.dump");
98+ let restored = fresh("rt", "restored.db");
99+ make_db(&src);
100+ dump_db(&src, &dump).unwrap();
101+ assert!(dump.exists());
102+ restore_db(&dump, &restored).unwrap();
103+ assert_eq!(row_count(&restored), 2);
104+ }
105+
106+ #[test]
107+ fn dump_is_a_valid_single_file_db() {
108+ let src = fresh("valid", "src.db");
109+ let dump = fresh("valid", "out.dump");
110+ make_db(&src);
111+ dump_db(&src, &dump).unwrap();
112+ // The dump opens directly as a database and holds the data.
113+ assert_eq!(row_count(&dump), 2);
114+ // No WAL/SHM sidecars: one self-contained file.
115+ assert!(!dump.with_extension("dump-wal").exists());
116+ assert!(!dump.with_extension("dump-shm").exists());
117+ }
118+
119+ #[test]
120+ fn dump_missing_source_errors() {
121+ let r = dump_db(&fresh("err", "nope.db"), &fresh("err", "out.dump"));
122+ assert!(r.is_err());
123+ }
124+
125+ #[test]
126+ fn restore_rejects_a_non_database() {
127+ let bad = fresh("bad", "bad.dump");
128+ std::fs::write(&bad, b"not a database").unwrap();
129+ let r = restore_db(&bad, &fresh("bad", "out.db"));
130+ assert!(r.is_err());
131+ }
132+
133+ #[test]
134+ fn redump_replaces_existing_dump() {
135+ let src = fresh("redump", "src.db");
136+ let dump = fresh("redump", "out.dump");
137+ make_db(&src);
138+ dump_db(&src, &dump).unwrap();
139+ // Second dump to the same path must succeed (not "target exists").
140+ dump_db(&src, &dump).unwrap();
141+ assert_eq!(row_count(&dump), 2);
142+ }
143+}
crates/knot-runtime/src/lib.rs+1
@@ -10,6 +10,7 @@
10// without benefit since they are never called from safe Rust.
10// without benefit since they are never called from safe Rust.
11#![allow(clippy::not_unsafe_ptr_arg_deref)]
11#![allow(clippy::not_unsafe_ptr_arg_deref)]
12
12
13+pub mod db_dump;
13pub mod log;
14pub mod log;
14mod tui;
15mod tui;
15
16