241 lines
7.6 KiB
Rust
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);
|
|
}
|