mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-10-30 12:59:38 -04:00
* Add to Library defaults: library settings with per-add overrides Every entry point used to carry its own notion of what a source starts as: volumes.track defaulted external drives to unfiltered, folder tracking to filtered, and the Explorer sent unfiltered: false by hand. The library now holds the defaults under settings.adding (store placement in library, an offline copy kept, filtered capture, content identified), config.library.get reports them and config.library.update changes them for later adds only. sources.track and volumes.track take the same AddOverrides on top; an absent field is the default and an override never writes back. The effective settings are the source's SourceConfig, persisted with the registration and returned in the output together with the saved name and the store path. A name, settings or volume row that fails to persist now fails the add instead of reporting a tracked source. A source added without content identification gets its walk and nothing after. volumes.track keeps the archival behavior by passing an explicit unfiltered override for an external drive unless the caller set one. The CLI gains --on-source, --in-library, --keep-offline-copy and --no-identify on sd source track. Generated TypeScript types regenerated. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Resolve store placement per library in the source layer Placement is intent recorded in each source's config column; the serving device resolves it. VolumeIndex::store_dir is the one resolver: in library under <data>/sources/<id>, on source under <root>/.spacedrive/sources/<id>, following the source's current root so an on-source store tracks its volume across remounts and resolves to nothing while the drive is away. Writer opens, read-only opens, freezes, the status surface and sources.list all go through it, and SourceStore::open takes the resolved stores directory rather than the data-directory layout. On source is refused for network and cloud volumes, whose WAL database cannot live on the share. The volume index also gains register_source_with, which registers under an identity a store on disk already carries, and forget_source, which drops a registration and retires its writer and reader handles without touching the directory. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Adding a path tracks its volume ensure_volume_in_db wrote the row but skipped the sync publication and the VolumeTracked event that track_volume emits, and the add logged its failure and carried on. It now syncs and emits on insert, the add fails when the row cannot be written, and the volume index maps the drive at the same moment so the mount point check and the detached flag apply to the new source. A second folder on the same drive finds the row already there. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Portable source identity and managed-directory exclusions Every store now carries source.json beside data.db: a versioned descriptor with the source id, the library that wrote it, its volume anchor and path within the volume, and its settings. Adding a scope first looks for a store whose descriptor binds to this library, this volume and this path, and registers under that identity, so a removed folder re-added or a drive plugged in with its catalog on it reopens the catalog instead of minting a fresh source over an empty one. A descriptor from another library or another drive is ignored and a new source starts beside it. The own-data-directory check becomes crate::config::is_managed: the data directory under every spelling, any .spacedrive directory and what is under it, and the volume identity marker. It is a path-component check rather than a registry lookup, so it holds for an unfiltered source whose rules are off, for an enclosing source that was never told about the inner store, for a store on a drive nobody here tracks, and across remounts and aliases. The walk, the watcher and the writer all use it. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Removing a source keeps its catalog unless asked to delete it sources.delete used to delete the store directory through the adapter engine before unregistering, which would have turned removal of an on-source store into destruction of the portable catalog, assertions included. It now retires the source's handles, drops the registration and leaves the store where it is; delete_catalog is an explicit second choice. The volume row is never touched: removing the last folder on a drive does not forget the hardware. sd source untrack wraps the action. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Add to Library acceptance suite and status One Core over temporary directories tracks a folder with defaults and one with overrides, proves one volume row for both, finds the on-source store excluded from its own and an enclosing source, removes a source and sees its catalog and record identities survive the re-add, and sees deletion start over. The suite joins the acceptance group of xtask test-core; docs/core/acceptance/add-to-library.md maps the plan's cases to tests and names the rows the core half cannot prove. PROJECT_STATUS and the plan's status line record what landed and what the modal thread builds next. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Review fixes: placement-aware reads and backup, re-track keeps settings, adoption across placements The library's per-source read queries (sources.list_items, list_records, media_listing) opened the store through the engine's in-library layout, so an on-source catalog was unreachable from the explorer; they now resolve a registered filesystem source through the volume index and fall through to the engine only for adapter sources. libraries.backup reads each store from where placement put it. A re-track carries the previous value of every setting the add did not name instead of the library's current defaults, and never changes placement. portable_identity searches both placements so a drive arriving with its catalog is adopted whatever placement the add asked for, and the adopted catalog keeps the placement it was found under. SourceConfig::default agrees with its serde defaults. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Restore names an on-source catalog it cannot put back The archive now holds a store backed up from its drive, but a restore lays every store in the in-library layout, where an on-source registration does not read. The manifest records each source's placement; a restore leaves an on-source catalog where it landed, keeps it out of the restored count, and names it in on_source_catalogs with its path, so the caller knows what was not put back. Moving it onto the drive is relocation. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * catalog_reused means an identity was kept, not that a file exists The acceptance run on CI saw a fresh add report its catalog reused: the content identity job of an enclosing source opened the new source's store between registration and the file probe. The flag now answers from the registration (it existed, or a descriptor-bound catalog was adopted), which is what it claims. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Regenerate TypeScript types for the catalog_reused doc Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> * Volume tracking test follows the one-library-per-path rule volumes.track now surfaces a source setup failure instead of logging it, so tracking a volume another open library holds is refused. The multi-library test asserts the refusal, then the handover once the first library untracks the drive and removes its source. Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai-staging[bot] <305915985+capy-ai-staging[bot]@users.noreply.github.com>
345 lines
9.9 KiB
Rust
345 lines
9.9 KiB
Rust
//! Peer rows of the R8 source runtime acceptance matrix
|
|
//! (`docs/core/acceptance/source-runtime.md`): two daemons in separate
|
|
//! processes, paired over loopback, replicating a source.
|
|
//!
|
|
//! Alice owns a source. Bob replicates it, then asks ten more times while
|
|
//! Alice is unchanged and must transfer nothing: same generation, same
|
|
//! loaded arena, same sync time. Alice then writes real files and re-walks,
|
|
//! and Bob's next passes must land the newer generation with every committed
|
|
//! record present.
|
|
|
|
use sd_core::{
|
|
ops::sources::track::action::track_and_index, service::mounts::peer, testing::CargoTestRunner,
|
|
Core,
|
|
};
|
|
use std::{env, path::PathBuf, time::Duration};
|
|
use tokio::time::timeout;
|
|
|
|
const TEST_DIR: &str = "/tmp/spacedrive-source-replication-test";
|
|
|
|
fn marker(name: &str) -> PathBuf {
|
|
PathBuf::from(TEST_DIR).join(name)
|
|
}
|
|
|
|
async fn wait_for_marker(name: &str, limit: Duration) {
|
|
let started = std::time::Instant::now();
|
|
while !marker(name).exists() {
|
|
assert!(
|
|
started.elapsed() < limit,
|
|
"timed out waiting for marker {name}"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
|
}
|
|
}
|
|
|
|
/// Files Alice's source holds at the start.
|
|
const INITIAL: [&str; 5] = [
|
|
"first.txt",
|
|
"second.txt",
|
|
"third.txt",
|
|
"notes/fourth.md",
|
|
"notes/fifth.md",
|
|
];
|
|
/// Files Alice adds after Bob has converged.
|
|
const LATER: [&str; 3] = ["later-one.txt", "later-two.txt", "notes/later-three.md"];
|
|
|
|
/// Alice: owns the source, pairs as initiator, then writes after Bob converges.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn alice_replication_scenario() {
|
|
if env::var("TEST_ROLE").unwrap_or_default() != "alice" {
|
|
return;
|
|
}
|
|
env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR);
|
|
let data_dir = PathBuf::from(TEST_DIR).join("alice");
|
|
let source_root = PathBuf::from(TEST_DIR).join("alice-source");
|
|
std::fs::create_dir_all(source_root.join("notes")).unwrap();
|
|
for name in INITIAL {
|
|
std::fs::write(source_root.join(name), name).unwrap();
|
|
}
|
|
|
|
println!("Alice: starting core");
|
|
let mut core = timeout(Duration::from_secs(10), Core::new(data_dir))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
core.device
|
|
.set_name("Alice's Replication Device".to_string())
|
|
.unwrap();
|
|
let library = core
|
|
.libraries
|
|
.create_library("Alice Replication Library", None, core.context.clone())
|
|
.await
|
|
.unwrap();
|
|
|
|
println!("Alice: tracking the source");
|
|
let tracked = track_and_index(
|
|
&library,
|
|
&core.context,
|
|
source_root.clone(),
|
|
None,
|
|
&Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
wait_for_walk(&library, tracked.job_id).await;
|
|
std::fs::write(marker("alice_source_id.txt"), tracked.id.to_string()).unwrap();
|
|
|
|
timeout(Duration::from_secs(10), core.init_networking())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
|
|
let networking = core.networking().expect("networking");
|
|
let (pairing_code, _) = timeout(
|
|
Duration::from_secs(15),
|
|
networking.start_pairing_as_initiator(false),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
std::fs::write(marker("pairing_code.txt"), &pairing_code).unwrap();
|
|
println!("Alice: pairing code written");
|
|
|
|
let started = std::time::Instant::now();
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
let connected = core.services.device.get_connected_devices().await.unwrap();
|
|
if !connected.is_empty() {
|
|
println!("Alice: Bob connected");
|
|
break;
|
|
}
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(60),
|
|
"Alice: Bob never connected"
|
|
);
|
|
}
|
|
|
|
// Bob says he has converged and watched ten unchanged passes.
|
|
wait_for_marker("bob_converged.txt", Duration::from_secs(120)).await;
|
|
println!("Alice: writing new files and re-walking");
|
|
for name in LATER {
|
|
std::fs::write(source_root.join(name), name).unwrap();
|
|
}
|
|
let rewalk = track_and_index(
|
|
&library,
|
|
&core.context,
|
|
source_root.clone(),
|
|
None,
|
|
&Default::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
wait_for_walk(&library, rewalk.job_id).await;
|
|
std::fs::write(marker("alice_wrote.txt"), "wrote").unwrap();
|
|
|
|
wait_for_marker("bob_success.txt", Duration::from_secs(120)).await;
|
|
std::fs::write(marker("alice_success.txt"), "success").unwrap();
|
|
println!("Alice: done");
|
|
}
|
|
|
|
async fn wait_for_walk(
|
|
library: &std::sync::Arc<sd_core::library::Library>,
|
|
job: Option<uuid::Uuid>,
|
|
) {
|
|
use sd_core::infra::job::types::JobId;
|
|
let Some(job) = job else { return };
|
|
if let Some(walk) = library.jobs().get_job(JobId(job)).await {
|
|
walk.wait().await.unwrap();
|
|
}
|
|
}
|
|
|
|
/// Bob: pairs as joiner and replicates Alice's source.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn bob_replication_scenario() {
|
|
if env::var("TEST_ROLE").unwrap_or_default() != "bob" {
|
|
return;
|
|
}
|
|
env::set_var("SPACEDRIVE_TEST_DIR", TEST_DIR);
|
|
let data_dir = PathBuf::from(TEST_DIR).join("bob");
|
|
|
|
println!("Bob: starting core");
|
|
let mut core = timeout(Duration::from_secs(10), Core::new(data_dir))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
core.device
|
|
.set_name("Bob's Replication Device".to_string())
|
|
.unwrap();
|
|
let _library = core
|
|
.libraries
|
|
.create_library("Bob Replication Library", None, core.context.clone())
|
|
.await
|
|
.unwrap();
|
|
timeout(Duration::from_secs(10), core.init_networking())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
tokio::time::sleep(Duration::from_secs(3)).await;
|
|
|
|
let pairing_code = loop {
|
|
if let Ok(code) = std::fs::read_to_string(marker("pairing_code.txt")) {
|
|
break code.trim().to_string();
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
};
|
|
let networking = core.networking().expect("networking");
|
|
timeout(
|
|
Duration::from_secs(15),
|
|
networking.start_pairing_as_joiner(&pairing_code, false),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
println!("Bob: joined pairing");
|
|
|
|
let started = std::time::Instant::now();
|
|
let alice = loop {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
let connected = core.services.device.get_connected_devices().await.unwrap();
|
|
if let Some(device) = connected.first() {
|
|
println!("Bob: connected to Alice {device}");
|
|
break *device;
|
|
}
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(60),
|
|
"Bob: Alice never connected"
|
|
);
|
|
};
|
|
|
|
let source_id: uuid::Uuid = std::fs::read_to_string(marker("alice_source_id.txt"))
|
|
.unwrap()
|
|
.trim()
|
|
.parse()
|
|
.unwrap();
|
|
let source_root = PathBuf::from(TEST_DIR).join("alice-source");
|
|
|
|
// First pass: the replica arrives with every initial record.
|
|
let share = {
|
|
let started = std::time::Instant::now();
|
|
loop {
|
|
match peer::sync_device(&core.context, alice, "alice".to_string()).await {
|
|
Ok(count) => println!("Bob: sync pass replicated {count} source(s)"),
|
|
Err(err) => println!("Bob: sync pass failed: {err}"),
|
|
}
|
|
if let Some(share) = peer::remote_share(source_id).await {
|
|
let index = share.index.read().await;
|
|
if INITIAL
|
|
.iter()
|
|
.all(|name| index.has_entry(&source_root.join(name)))
|
|
{
|
|
drop(index);
|
|
break share;
|
|
}
|
|
}
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(60),
|
|
"Bob: the replica never arrived with every initial record"
|
|
);
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
};
|
|
println!(
|
|
"Bob: converged at generation {} (synced_at {})",
|
|
share.generation, share.synced_at_secs
|
|
);
|
|
assert_ne!(share.generation, 0, "the owner describes its artifact");
|
|
|
|
// R8 "Unchanged owner for ten intervals": ten more passes move nothing.
|
|
for pass in 1..=10 {
|
|
peer::sync_device(&core.context, alice, "alice".to_string())
|
|
.await
|
|
.expect("sync pass");
|
|
let again = peer::remote_share(source_id).await.expect("share stays");
|
|
assert_eq!(
|
|
again.generation, share.generation,
|
|
"pass {pass}: the generation moved with no writes on the owner"
|
|
);
|
|
assert_eq!(
|
|
again.synced_at_secs, share.synced_at_secs,
|
|
"pass {pass}: a transfer happened with no writes on the owner"
|
|
);
|
|
assert!(
|
|
std::sync::Arc::ptr_eq(&again.index, &share.index),
|
|
"pass {pass}: the arena was reconstructed with no writes on the owner"
|
|
);
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
println!("Bob: ten unchanged passes transferred nothing");
|
|
std::fs::write(marker("bob_converged.txt"), "converged").unwrap();
|
|
|
|
// R8 "Continuous real writes": the owner's new records reach the
|
|
// replica at a newer generation, with nothing committed lost.
|
|
wait_for_marker("alice_wrote.txt", Duration::from_secs(120)).await;
|
|
let started = std::time::Instant::now();
|
|
loop {
|
|
if let Err(err) = peer::sync_device(&core.context, alice, "alice".to_string()).await {
|
|
println!("Bob: sync pass failed: {err}");
|
|
}
|
|
let newer = peer::remote_share(source_id).await.expect("share stays");
|
|
let index = newer.index.read().await;
|
|
let complete = INITIAL
|
|
.iter()
|
|
.chain(LATER.iter())
|
|
.all(|name| index.has_entry(&source_root.join(name)));
|
|
if complete {
|
|
assert_ne!(
|
|
newer.generation, share.generation,
|
|
"new records arrived under the old generation"
|
|
);
|
|
println!(
|
|
"Bob: newest generation {} holds every record",
|
|
newer.generation
|
|
);
|
|
break;
|
|
}
|
|
assert!(
|
|
started.elapsed() < Duration::from_secs(60),
|
|
"Bob: the owner's writes never reached the replica"
|
|
);
|
|
drop(index);
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
}
|
|
|
|
std::fs::write(marker("bob_success.txt"), "success").unwrap();
|
|
println!("Bob: done");
|
|
}
|
|
|
|
/// Orchestrator: spawns Alice and Bob and waits for both markers.
|
|
#[tokio::test]
|
|
async fn test_source_replication() {
|
|
let _ = std::fs::remove_dir_all(TEST_DIR);
|
|
std::fs::create_dir_all(TEST_DIR).unwrap();
|
|
|
|
let mut runner = CargoTestRunner::for_test_file("source_replication_test")
|
|
.with_timeout(Duration::from_secs(300))
|
|
.add_subprocess("alice", "alice_replication_scenario")
|
|
.add_subprocess("bob", "bob_replication_scenario");
|
|
|
|
runner
|
|
.spawn_single_process("alice")
|
|
.await
|
|
.expect("Failed to spawn Alice");
|
|
tokio::time::sleep(Duration::from_secs(8)).await;
|
|
runner
|
|
.spawn_single_process("bob")
|
|
.await
|
|
.expect("Failed to spawn Bob");
|
|
|
|
let result = runner
|
|
.wait_for_success(|_| {
|
|
marker("alice_success.txt").exists() && marker("bob_success.txt").exists()
|
|
})
|
|
.await;
|
|
|
|
if let Err(e) = result {
|
|
println!("Source replication test failed: {e}");
|
|
for (name, output) in runner.get_all_outputs() {
|
|
println!("\n{name} output:\n{output}");
|
|
}
|
|
panic!("source replication test failed");
|
|
}
|
|
}
|