spacedrive/core/tests/sync_backfill_test.rs
Jamie Pine 69e825fe06 Revert sync to November 14th - Add new sync tests
1. Watermark Messages: - Nov 16: Simple - single my_state_watermark,
single state_watermark in response - Main: Complex - per-resource
my_resource_watermarks HashMap, resource counts, content hashes 2.
DataAvailableNotification: - Nov 16: Doesn't exist - Main: New message
type that triggers watermark exchanges 3. FK Mapper: - Nov 16: Sets
missing FKs to NULL, applies all records - Main: Marks records with
missing FKs as failed, filters them out 4. Watermark Storage (internal,
not in messages): - Nov 16: Single last_watermark per resource - Main:
Dual watermarks - cursor_watermark and
2025-11-24 14:27:32 -08:00

517 lines
14 KiB
Rust

//! Initial Backfill Sync Test
//!
//! Tests the scenario where one device indexes completely before the second device connects.
//! This validates backfill behavior and content_id linkage without real-time sync complexity.
mod helpers;
use helpers::MockTransport;
use sd_core::{
infra::{db::entities, sync::NetworkTransport},
library::Library,
service::Service,
Core,
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use std::sync::Arc;
use tokio::{fs, time::Duration};
use uuid::Uuid;
fn init_tracing(test_name: &str, snapshot_dir: &std::path::Path) -> anyhow::Result<()> {
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
let log_file = std::fs::File::create(snapshot_dir.join("test.log"))?;
let _ = tracing_subscriber::registry()
.with(
fmt::layer()
.with_target(true)
.with_thread_ids(true)
.with_ansi(false)
.with_writer(log_file),
)
.with(fmt::layer().with_target(true).with_thread_ids(true))
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"sd_core::service::sync=debug,\
sd_core::service::sync::peer=debug,\
sd_core::service::sync::backfill=debug,\
sd_core::service::sync::dependency=debug,\
sd_core::infra::sync=debug,\
sd_core::infra::db::entities=debug,\
sync_backfill_test=debug,\
helpers=trace",
)
}))
.try_init();
tracing::info!(
snapshot_dir = %snapshot_dir.display(),
"Initialized logging for {}",
test_name
);
Ok(())
}
fn create_test_config(data_dir: &std::path::Path) -> anyhow::Result<sd_core::config::AppConfig> {
let logging_config = sd_core::config::LoggingConfig {
main_filter: "sd_core=info".to_string(),
streams: vec![sd_core::config::LogStreamConfig {
name: "sync".to_string(),
file_name: "sync.log".to_string(),
filter: "sd_core::service::sync=trace,\
sd_core::service::network::protocol::sync=trace,\
sd_core::infra::sync=trace,\
sd_core::service::sync::peer=trace,\
sd_core::service::sync::backfill=trace,\
sd_core::infra::db::entities::entry=debug,\
sd_core::infra::db::entities::device=debug,\
sd_core::infra::db::entities::location=debug"
.to_string(),
enabled: true,
}],
};
let config = sd_core::config::AppConfig {
version: 4,
logging: logging_config,
data_dir: data_dir.to_path_buf(),
log_level: "debug".to_string(),
telemetry_enabled: false,
preferences: sd_core::config::Preferences::default(),
job_logging: sd_core::config::JobLoggingConfig::default(),
services: sd_core::config::ServiceConfig {
networking_enabled: false,
volume_monitoring_enabled: false,
location_watcher_enabled: false,
},
};
config.save()?;
Ok(config)
}
async fn wait_for_indexing(library: &Arc<Library>, _location_id: i32) -> anyhow::Result<()> {
use sd_core::infra::job::JobStatus;
let start_time = tokio::time::Instant::now();
let timeout_duration = Duration::from_secs(120);
let mut job_seen = false;
let mut last_entry_count = 0;
let mut stable_iterations = 0;
loop {
let running_jobs = library.jobs().list_jobs(Some(JobStatus::Running)).await?;
if !running_jobs.is_empty() {
job_seen = true;
tracing::debug!(running_count = running_jobs.len(), "Indexing jobs still running");
}
let current_entries = entities::entry::Entity::find()
.count(library.db().conn())
.await?;
let completed_jobs = library.jobs().list_jobs(Some(JobStatus::Completed)).await?;
if job_seen
&& !completed_jobs.is_empty()
&& running_jobs.is_empty()
&& current_entries > 0
{
if current_entries == last_entry_count {
stable_iterations += 1;
if stable_iterations >= 3 {
tracing::info!(
total_entries = current_entries,
"Indexing completed and stabilized"
);
break;
}
} else {
stable_iterations = 0;
}
last_entry_count = current_entries;
}
let failed_jobs = library.jobs().list_jobs(Some(JobStatus::Failed)).await?;
if !failed_jobs.is_empty() {
anyhow::bail!("Indexing job failed");
}
if start_time.elapsed() > timeout_duration {
anyhow::bail!(
"Indexing timeout after {:?} (entries: {})",
timeout_duration,
current_entries
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Ok(())
}
async fn register_device(
library: &Arc<Library>,
device_id: Uuid,
device_name: &str,
) -> anyhow::Result<()> {
use chrono::Utc;
let device_model = entities::device::ActiveModel {
id: sea_orm::ActiveValue::NotSet,
uuid: Set(device_id),
name: Set(device_name.to_string()),
os: Set("Test OS".to_string()),
os_version: Set(Some("1.0".to_string())),
hardware_model: Set(None),
network_addresses: Set(serde_json::json!([])),
is_online: Set(false),
last_seen_at: Set(Utc::now()),
capabilities: Set(serde_json::json!({})),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
sync_enabled: Set(true),
last_sync_at: Set(None),
slug: Set(device_name.to_lowercase()),
};
device_model.insert(library.db().conn()).await?;
Ok(())
}
#[tokio::test]
async fn test_initial_backfill_alice_indexes_first() -> anyhow::Result<()> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let test_root =
std::path::PathBuf::from(home).join("Library/Application Support/spacedrive/sync_tests");
let data_dir = test_root.join("data");
fs::create_dir_all(&data_dir).await?;
let temp_dir_alice = data_dir.join("alice_backfill");
let temp_dir_bob = data_dir.join("bob_backfill");
fs::create_dir_all(&temp_dir_alice).await?;
fs::create_dir_all(&temp_dir_bob).await?;
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let snapshot_dir = test_root
.join("snapshots")
.join(format!("backfill_alice_first_{}", timestamp));
fs::create_dir_all(&snapshot_dir).await?;
init_tracing("backfill_alice_first", &snapshot_dir)?;
tracing::info!(
test_root = %test_root.display(),
snapshot_dir = %snapshot_dir.display(),
alice_dir = %temp_dir_alice.display(),
bob_dir = %temp_dir_bob.display(),
"Test directories initialized"
);
create_test_config(&temp_dir_alice)?;
create_test_config(&temp_dir_bob)?;
tracing::info!("=== Phase 1: Alice indexes location (Bob not connected yet) ===");
let core_alice = Core::new(temp_dir_alice.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to create Alice core: {}", e))?;
let device_alice_id = core_alice.device.device_id()?;
let library_alice = core_alice
.libraries
.create_library_no_sync("Backfill Test Library", None, core_alice.context.clone())
.await?;
use sd_core::location::{create_location, IndexMode, LocationCreateArgs};
let device_record = entities::device::Entity::find()
.one(library_alice.db().conn())
.await?
.ok_or_else(|| anyhow::anyhow!("Device not found"))?;
let downloads_path = std::env::var("HOME").unwrap() + "/Desktop";
let location_args = LocationCreateArgs {
path: std::path::PathBuf::from(&downloads_path),
name: Some("Desktop".to_string()),
index_mode: IndexMode::Content,
};
let location_db_id = create_location(
library_alice.clone(),
library_alice.event_bus(),
location_args,
device_record.id,
)
.await?;
tracing::info!(
location_id = location_db_id,
"Location created on Alice, waiting for indexing"
);
wait_for_indexing(&library_alice, location_db_id).await?;
let alice_entries_after_index = entities::entry::Entity::find()
.count(library_alice.db().conn())
.await?;
let alice_content_after_index = entities::content_identity::Entity::find()
.count(library_alice.db().conn())
.await?;
tracing::info!(
entries = alice_entries_after_index,
content_identities = alice_content_after_index,
"Alice indexing complete"
);
tracing::info!("=== Phase 2: Bob connects and starts backfill ===");
let core_bob = Core::new(temp_dir_bob.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to create Bob core: {}", e))?;
let device_bob_id = core_bob.device.device_id()?;
let library_bob = core_bob
.libraries
.create_library_no_sync("Backfill Test Library", None, core_bob.context.clone())
.await?;
register_device(&library_alice, device_bob_id, "Bob").await?;
register_device(&library_bob, device_alice_id, "Alice").await?;
let (transport_alice, transport_bob) =
MockTransport::new_pair(device_alice_id, device_bob_id);
library_alice
.init_sync_service(
device_alice_id,
transport_alice.clone() as Arc<dyn NetworkTransport>,
)
.await?;
library_bob
.init_sync_service(
device_bob_id,
transport_bob.clone() as Arc<dyn NetworkTransport>,
)
.await?;
transport_alice
.register_sync_service(
device_alice_id,
Arc::downgrade(library_alice.sync_service().unwrap()),
)
.await;
transport_bob
.register_sync_service(
device_bob_id,
Arc::downgrade(library_bob.sync_service().unwrap()),
)
.await;
library_alice.sync_service().unwrap().start().await?;
library_bob.sync_service().unwrap().start().await?;
tracing::info!("Sync services started - backfill should begin automatically");
// Give sync loop a moment to start
tokio::time::sleep(Duration::from_millis(500)).await;
let bob_state = library_bob
.sync_service()
.unwrap()
.peer_sync()
.state()
.await;
let alice_state = library_alice
.sync_service()
.unwrap()
.peer_sync()
.state()
.await;
tracing::info!(
bob_state = ?bob_state,
alice_state = ?alice_state,
"Initial sync states after startup"
);
// Check if Bob can see Alice as a connected partner
let partners = transport_bob
.get_connected_sync_partners(
library_bob.id(),
library_bob.db().conn(),
)
.await?;
tracing::info!(
partners = ?partners,
alice_device = %device_alice_id,
bob_device = %device_bob_id,
"Bob's view of connected sync partners"
);
if partners.is_empty() {
anyhow::bail!("Bob cannot see any connected partners! Backfill won't trigger.");
}
tracing::info!("=== Phase 3: Waiting for backfill to complete ===");
let start = tokio::time::Instant::now();
let max_duration = Duration::from_secs(60);
let mut last_bob_entries = 0;
let mut last_bob_content = 0;
let mut stable_iterations = 0;
let mut no_progress_iterations = 0;
while start.elapsed() < max_duration {
let bob_entries = entities::entry::Entity::find()
.count(library_bob.db().conn())
.await?;
let bob_content = entities::content_identity::Entity::find()
.count(library_bob.db().conn())
.await?;
let bob_state = library_bob
.sync_service()
.unwrap()
.peer_sync()
.state()
.await;
// Check if we're making progress
if bob_entries == last_bob_entries && bob_content == last_bob_content {
no_progress_iterations += 1;
if no_progress_iterations >= 20 {
tracing::warn!(
bob_entries = bob_entries,
alice_entries = alice_entries_after_index,
bob_state = ?bob_state,
elapsed_secs = start.elapsed().as_secs(),
"No progress for 20 iterations - backfill may be stuck"
);
}
} else {
no_progress_iterations = 0;
}
// Check if sync is complete
if bob_entries == alice_entries_after_index && bob_content == alice_content_after_index {
stable_iterations += 1;
if stable_iterations >= 5 {
tracing::info!(
duration_ms = start.elapsed().as_millis(),
bob_entries = bob_entries,
bob_content = bob_content,
bob_state = ?bob_state,
"Backfill complete and stable"
);
break;
}
} else {
stable_iterations = 0;
}
if bob_entries != last_bob_entries || bob_content != last_bob_content {
let entry_progress = if alice_entries_after_index > 0 {
(bob_entries as f64 / alice_entries_after_index as f64 * 100.0)
} else {
0.0
};
let content_progress = if alice_content_after_index > 0 {
(bob_content as f64 / alice_content_after_index as f64 * 100.0)
} else {
0.0
};
tracing::info!(
bob_entries = bob_entries,
bob_content = bob_content,
alice_entries = alice_entries_after_index,
alice_content = alice_content_after_index,
entry_progress_pct = format!("{:.1}", entry_progress),
content_progress_pct = format!("{:.1}", content_progress),
bob_state = ?bob_state,
elapsed_secs = start.elapsed().as_secs(),
"Backfill in progress"
);
}
last_bob_entries = bob_entries;
last_bob_content = bob_content;
tokio::time::sleep(Duration::from_millis(100)).await;
}
let bob_entries_final = entities::entry::Entity::find()
.count(library_bob.db().conn())
.await?;
let bob_content_final = entities::content_identity::Entity::find()
.count(library_bob.db().conn())
.await?;
tracing::info!(
alice_entries = alice_entries_after_index,
bob_entries = bob_entries_final,
alice_content = alice_content_after_index,
bob_content = bob_content_final,
"=== Final counts ==="
);
let entry_diff = (alice_entries_after_index as i64 - bob_entries_final as i64).abs();
let content_diff = (alice_content_after_index as i64 - bob_content_final as i64).abs();
assert!(
entry_diff <= 5,
"Entry count mismatch after backfill: Alice has {}, Bob has {} (diff: {})",
alice_entries_after_index,
bob_entries_final,
entry_diff
);
assert!(
content_diff <= 5,
"Content identity count mismatch after backfill: Alice has {}, Bob has {} (diff: {})",
alice_content_after_index,
bob_content_final,
content_diff
);
let bob_files_linked = entities::entry::Entity::find()
.filter(entities::entry::Column::Kind.eq(0))
.filter(entities::entry::Column::ContentId.is_not_null())
.count(library_bob.db().conn())
.await?;
let bob_total_files = entities::entry::Entity::find()
.filter(entities::entry::Column::Kind.eq(0))
.count(library_bob.db().conn())
.await?;
let linkage_pct = if bob_total_files > 0 {
(bob_files_linked * 100) / bob_total_files
} else {
0
};
tracing::info!(
bob_files_linked = bob_files_linked,
bob_total_files = bob_total_files,
linkage_pct = linkage_pct,
"Bob's content_id linkage after backfill"
);
assert!(
linkage_pct >= 90,
"Content_id linkage too low on Bob after backfill: {}% (expected >= 90%)",
linkage_pct
);
Ok(())
}