mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
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
517 lines
14 KiB
Rust
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(())
|
|
}
|