tidaldb/tidal/tests/backup.rs
2026-02-23 22:41:16 -07:00

241 lines
7.6 KiB
Rust

#![allow(clippy::unwrap_used)]
//! Integration tests for `TidalDb::create_backup`.
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use std::time::Duration;
use tidaldb::schema::validation::DecaySpec;
use tidaldb::schema::validation::builders::SchemaBuilder;
use tidaldb::schema::{EntityId, EntityKind, Timestamp, Window};
use tidaldb::{TidalDb, TidalError};
/// Build a schema with a single "view" signal type.
fn test_schema() -> tidaldb::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[Window::OneHour])
.velocity(false)
.add();
builder.build().unwrap()
}
/// Build a persistent test database at `path` with the standard schema.
fn build_persistent_db(path: &std::path::Path) -> TidalDb {
TidalDb::builder()
.with_data_dir(path)
.with_schema(test_schema())
.open()
.unwrap()
}
#[test]
fn backup_creates_copy_of_data() {
let src_dir = tempfile::tempdir().unwrap();
let bak_dir = tempfile::tempdir().unwrap();
let backup_path = bak_dir.path().join("backup");
let db = build_persistent_db(src_dir.path());
// Write some items using write_item_with_metadata so they appear in
// the universe bitmap (which item_count() reads).
for i in 1..=10 {
let mut meta = HashMap::new();
meta.insert("title".to_string(), format!("Item {i}"));
db.write_item_with_metadata(EntityId::new(i), &meta)
.unwrap();
}
// Write some signals.
for i in 1..=10 {
db.signal("view", EntityId::new(i), 1.0, Timestamp::now())
.unwrap();
}
let info = db.create_backup(&backup_path).unwrap();
assert!(info.size_bytes > 0, "backup should contain data");
assert_eq!(info.path, backup_path);
assert_eq!(info.item_count, 10);
assert!(info.timestamp_ns > 0);
// Close the original database (releases the lock).
db.close().unwrap();
// Open the backup and verify items survived in storage.
// NOTE: item_count() reads the in-memory universe bitmap which is only
// populated during write_item_with_metadata(); on reopen it starts empty
// (bitmap indexes are not persisted). Instead, verify via get_item_metadata.
let db2 = TidalDb::builder()
.with_data_dir(&backup_path)
.with_schema(test_schema())
.open()
.unwrap();
for i in 1..=10 {
let meta = db2.get_item_metadata(EntityId::new(i)).unwrap();
assert!(meta.is_some(), "item {i} should exist in the backup");
let meta = meta.unwrap();
assert_eq!(
meta.get("title").map(String::as_str),
Some(format!("Item {i}").as_str()),
"item {i} title should match"
);
}
db2.close().unwrap();
}
#[test]
fn backup_fails_for_ephemeral_db() {
let db = TidalDb::builder()
.ephemeral()
.with_schema(test_schema())
.open()
.unwrap();
let bak_dir = tempfile::tempdir().unwrap();
let result = db.create_backup(bak_dir.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("persistent"),
"expected persistent error, got: {err_msg}"
);
}
#[test]
fn backup_preserves_signal_scores() {
let src_dir = tempfile::tempdir().unwrap();
let bak_dir = tempfile::tempdir().unwrap();
let backup_path = bak_dir.path().join("backup");
let db = build_persistent_db(src_dir.path());
let entity = EntityId::new(42);
let mut meta = HashMap::new();
meta.insert("title".to_string(), "Test Item".to_string());
db.write_item_with_metadata(entity, &meta).unwrap();
// Record several signals to build up a score.
for _ in 0..5 {
db.signal("view", entity, 1.0, Timestamp::now()).unwrap();
}
// Read the score before backup.
let score_before = db.read_decay_score(entity, "view", 0).unwrap();
assert!(
score_before.is_some(),
"should have a score after 5 signals"
);
let _info = db.create_backup(&backup_path).unwrap();
db.close().unwrap();
// Open backup and verify scores survived.
let db2 = TidalDb::builder()
.with_data_dir(&backup_path)
.with_schema(test_schema())
.open()
.unwrap();
let score_after = db2.read_decay_score(entity, "view", 0).unwrap();
assert!(
score_after.is_some(),
"backup should preserve signal scores"
);
// The scores should be very close (tiny decay between backup and reopen).
// With a 7-day half-life, a few seconds of elapsed time produces
// negligible decay, so we allow up to 0.5 drift.
let before = score_before.unwrap();
let after = score_after.unwrap();
let diff = (before - after).abs();
assert!(
diff < 0.5,
"signal score drift should be minimal: before={before}, after={after}, diff={diff}"
);
db2.close().unwrap();
}
#[test]
fn backup_returns_backpressure_during_backup() {
// Verify the atomic flag mechanism works by manipulating it directly.
// A true integration test with concurrent backup + signal is racy and
// non-deterministic, so we test the invariant: when backup_in_progress
// is true, signal() returns Backpressure.
let src_dir = tempfile::tempdir().unwrap();
let db = build_persistent_db(src_dir.path());
// Manually set the backup flag (simulating an in-progress backup).
db.backup_in_progress_flag().store(true, Ordering::Release);
let result = db.signal("view", EntityId::new(1), 1.0, Timestamp::now());
assert!(result.is_err());
match result.unwrap_err() {
TidalError::Backpressure { retry_after_ms } => {
assert!(retry_after_ms > 0, "retry_after_ms should be positive");
}
other => panic!("expected Backpressure, got: {other}"),
}
// Clear the flag -- writes should succeed again.
db.backup_in_progress_flag().store(false, Ordering::Release);
let result = db.signal("view", EntityId::new(1), 1.0, Timestamp::now());
assert!(
result.is_ok(),
"signal should succeed after backup completes"
);
}
#[test]
fn backup_skips_lock_file() {
let src_dir = tempfile::tempdir().unwrap();
let bak_dir = tempfile::tempdir().unwrap();
let backup_path = bak_dir.path().join("backup");
let db = build_persistent_db(src_dir.path());
// The lock file should exist in src_dir.
assert!(
src_dir.path().join("tidaldb.lock").exists(),
"lock file should exist for persistent db"
);
let _info = db.create_backup(&backup_path).unwrap();
// The lock file should NOT be in the backup.
assert!(
!backup_path.join("tidaldb.lock").exists(),
"backup should not include tidaldb.lock"
);
db.close().unwrap();
}
#[test]
fn backup_concurrent_backup_rejected() {
let src_dir = tempfile::tempdir().unwrap();
let db = build_persistent_db(src_dir.path());
// Set the flag to simulate an in-progress backup.
db.backup_in_progress_flag().store(true, Ordering::Release);
let bak_dir = tempfile::tempdir().unwrap();
let result = db.create_backup(bak_dir.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("already in progress"),
"expected concurrent backup error, got: {err_msg}"
);
// Clean up the flag.
db.backup_in_progress_flag().store(false, Ordering::Release);
}