use lora_database::{
Compression, Database, ExecuteOptions, PasswordKdfParams, QueryResult, ResultFormat,
SnapshotOptions, SnapshotPassword, DATABASE_SNAPSHOT_MAGIC,
};
fn opts() -> Option<ExecuteOptions> {
Some(ExecuteOptions {
format: ResultFormat::RowArrays,
})
}
fn row_count(result: QueryResult) -> usize {
match result {
QueryResult::RowArrays(r) => r.rows.len(),
other => panic!("expected RowArrays, got {:?}", other),
}
}
fn encrypted_snapshot_options() -> SnapshotOptions {
SnapshotOptions {
compression: Compression::Gzip { level: 1 },
encryption: Some(
SnapshotPassword::with_params(
"test-password",
"correct horse battery staple",
PasswordKdfParams {
memory_cost_kib: 512,
time_cost: 1,
parallelism: 1,
},
)
.into(),
),
}
}
#[test]
fn save_and_load_roundtrip_through_filesystem() {
let dir = tempdir_like("save_and_load_roundtrip");
let path = dir.join("snap.bin");
{
let db = Database::in_memory();
db.execute("CREATE (:Person {name: 'Alice'})", opts())
.unwrap();
db.execute("CREATE (:Person {name: 'Bob'})", opts())
.unwrap();
db.execute(
"MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) \
CREATE (a)-[:KNOWS]->(b)",
opts(),
)
.unwrap();
let meta = db.save_snapshot_to(&path).unwrap();
assert_eq!(meta.node_count, 2);
assert_eq!(meta.relationship_count, 1);
assert_eq!(meta.wal_lsn, None);
}
let db = Database::in_memory_from_snapshot(&path).unwrap();
assert_eq!(db.node_count(), 2);
assert_eq!(db.relationship_count(), 1);
let rows = row_count(
db.execute(
"MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name",
opts(),
)
.unwrap(),
);
assert_eq!(rows, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_replaces_existing_state() {
let dir = tempdir_like("load_replaces_existing");
let path = dir.join("snap.bin");
let donor = Database::in_memory();
donor.execute("CREATE (:A {n: 1})", opts()).unwrap();
donor.save_snapshot_to(&path).unwrap();
let target = Database::in_memory();
target.execute("CREATE (:B {n: 2})", opts()).unwrap();
target.execute("CREATE (:B {n: 3})", opts()).unwrap();
assert_eq!(target.node_count(), 2);
target.load_snapshot_from(&path).unwrap();
assert_eq!(target.node_count(), 1);
let rows = row_count(target.execute("MATCH (x:A) RETURN x", opts()).unwrap());
assert_eq!(rows, 1);
let rows = row_count(target.execute("MATCH (x:B) RETURN x", opts()).unwrap());
assert_eq!(rows, 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_leaves_no_tmp_file() {
let dir = tempdir_like("atomic_write");
let path = dir.join("snap.bin");
let db = Database::in_memory();
db.execute("CREATE (:N)", opts()).unwrap();
db.save_snapshot_to(&path).unwrap();
assert!(path.exists());
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = std::path::PathBuf::from(tmp);
assert!(!tmp.exists(), "stale .tmp file found at {}", tmp.display());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn snapshot_survives_new_writes_after_restore() {
let dir = tempdir_like("post_restore_writes");
let path = dir.join("snap.bin");
let donor = Database::in_memory();
donor.execute("CREATE (:Counter {n: 0})", opts()).unwrap();
donor.save_snapshot_to(&path).unwrap();
let db = Database::in_memory_from_snapshot(&path).unwrap();
db.execute("CREATE (:Counter {n: 1})", opts()).unwrap();
db.execute("CREATE (:Counter {n: 2})", opts()).unwrap();
assert_eq!(db.node_count(), 3);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn database_snapshot_bytes_roundtrip_through_columnar_format() {
let donor = Database::in_memory();
donor
.execute("CREATE (:Person {name: 'Alice'})", opts())
.unwrap();
donor
.execute("CREATE (:Person {name: 'Bob'})", opts())
.unwrap();
donor
.execute(
"MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'}) \
CREATE (a)-[:KNOWS]->(b)",
opts(),
)
.unwrap();
let bytes = donor.save_snapshot_to_bytes().unwrap();
assert!(bytes.starts_with(DATABASE_SNAPSHOT_MAGIC));
let target = Database::in_memory();
let meta = target.load_snapshot_from_bytes(&bytes).unwrap();
assert_eq!(meta.node_count, 2);
assert_eq!(meta.relationship_count, 1);
assert_eq!(target.node_count(), 2);
assert_eq!(target.relationship_count(), 1);
}
#[test]
fn database_snapshot_bytes_roundtrip_with_gzip_compression() {
let donor = Database::in_memory();
let repeated = "x".repeat(512);
for index in 0_i64..32 {
donor
.execute_with_params(
"CREATE (:Compressed {i: $i, repeated: $repeated})",
opts(),
[
("i".to_string(), lora_database::LoraValue::Int(index)),
(
"repeated".to_string(),
lora_database::LoraValue::String(repeated.clone()),
),
]
.into(),
)
.unwrap();
}
let plain = donor.save_snapshot_to_bytes().unwrap();
let options = SnapshotOptions {
compression: Compression::Gzip { level: 1 },
encryption: None,
};
let (compressed, info) = donor.save_snapshot_to_bytes_with_options(&options).unwrap();
assert_eq!(info.compression, Compression::Gzip { level: 1 });
assert!(compressed.starts_with(DATABASE_SNAPSHOT_MAGIC));
assert!(compressed.len() < plain.len());
let target = Database::in_memory();
let meta = target.load_snapshot_from_bytes(&compressed).unwrap();
assert_eq!(meta.node_count, 32);
let rows = row_count(
target
.execute("MATCH (n:Compressed) RETURN n", opts())
.unwrap(),
);
assert_eq!(rows, 32);
}
#[test]
fn database_snapshot_bytes_roundtrip_with_password_encryption() {
let donor = Database::in_memory();
donor
.execute("CREATE (:Secret {name: 'Ada'})", opts())
.unwrap();
let options = encrypted_snapshot_options();
let credentials = options.encryption.clone();
let (bytes, info) = donor.save_snapshot_to_bytes_with_options(&options).unwrap();
assert!(bytes.starts_with(DATABASE_SNAPSHOT_MAGIC));
assert!(info.encrypted);
assert_eq!(info.key_id.as_deref(), Some("test-password"));
let target = Database::in_memory();
assert!(target.load_snapshot_from_bytes(&bytes).is_err());
let meta = target
.load_snapshot_from_bytes_with_credentials(&bytes, credentials.as_ref())
.unwrap();
assert_eq!(meta.node_count, 1);
let rows = row_count(target.execute("MATCH (n:Secret) RETURN n", opts()).unwrap());
assert_eq!(rows, 1);
}
#[test]
fn database_snapshot_file_roundtrip_with_password_encryption() {
let dir = tempdir_like("encrypted_database_snapshot_file");
let path = dir.join("secret.lsnap");
let donor = Database::in_memory();
donor
.execute("CREATE (:Secret {name: 'Ada'})", opts())
.unwrap();
let options = encrypted_snapshot_options();
let credentials = options.encryption.clone();
let meta = donor
.save_snapshot_to_with_options(&path, &options)
.unwrap();
assert_eq!(meta.node_count, 1);
let target = Database::in_memory();
assert!(target
.load_snapshot_from_with_credentials(&path, None)
.is_err());
let meta = target
.load_snapshot_from_with_credentials(&path, credentials.as_ref())
.unwrap();
assert_eq!(meta.node_count, 1);
let rows = row_count(target.execute("MATCH (n:Secret) RETURN n", opts()).unwrap());
assert_eq!(rows, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_save_cleans_up_tmp_file() {
let dir = tempdir_like("failed_save");
let target = dir.join("snap.bin");
std::fs::create_dir_all(&target).unwrap();
let db = Database::in_memory();
db.execute("CREATE (:N)", opts()).unwrap();
let err = db.save_snapshot_to(&target);
assert!(
err.is_err(),
"expected save to fail because target is a directory"
);
let mut tmp = target.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = std::path::PathBuf::from(tmp);
assert!(
!tmp.exists(),
"stale .tmp file at {} after failed save",
tmp.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn failed_load_preserves_existing_state() {
let dir = tempdir_like("failed_load");
let path = dir.join("bad.bin");
std::fs::write(&path, b"not a snapshot at all").unwrap();
let db = Database::in_memory();
db.execute("CREATE (:Keep {n: 1})", opts()).unwrap();
db.execute("CREATE (:Keep {n: 2})", opts()).unwrap();
assert_eq!(db.node_count(), 2);
let err = db.load_snapshot_from(&path);
assert!(err.is_err(), "expected load to fail on garbage file");
assert_eq!(db.node_count(), 2);
let rows = row_count(db.execute("MATCH (x:Keep) RETURN x", opts()).unwrap());
assert_eq!(rows, 2);
let _ = std::fs::remove_dir_all(&dir);
}
fn tempdir_like(tag: &str) -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"lora-snap-test-{}-{}-{}",
tag,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}