#![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); }