From 7effa7c64c63637ae53c4a169bcaeb06c2205c4b Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Mon, 29 Sep 2025 19:56:50 +0200 Subject: [PATCH] node: Report sync status for given namespaces The "seeds" command does only take a repository ID, and carries the implicit assumption that the namespace with the same public key as the Node ID of the receiving `radicle-node` process should be used as reference for determining whether other nodes are in sync, but no other namespace. In the spirit of separating user and node identity, relax this, so that the command also carries the public keys relative to which the sync status should be computed. This allows to inspect sync status for arbitrary namespaces via command. For now, this feature is not exposed to the CLI, but `rad sync status` always passes the public key of the active profile. --- crates/radicle-cli/src/commands/sync.rs | 4 +-- crates/radicle-cli/src/node.rs | 2 +- crates/radicle-cli/tests/commands.rs | 2 +- crates/radicle-node/src/control.rs | 6 +++++ crates/radicle-node/src/runtime/handle.rs | 12 +++++++-- crates/radicle-node/src/test/handle.rs | 6 ++++- crates/radicle-node/src/test/node.rs | 2 +- crates/radicle-node/src/tests/e2e.rs | 4 +-- crates/radicle-protocol/src/service.rs | 28 ++++++++++++-------- crates/radicle/CHANGELOG.md | 5 ++++ crates/radicle/src/node.rs | 31 ++++++++++++++++++++--- crates/radicle/src/node/command.rs | 19 +++++++++++++- 12 files changed, 95 insertions(+), 26 deletions(-) diff --git a/crates/radicle-cli/src/commands/sync.rs b/crates/radicle-cli/src/commands/sync.rs index 73b2cd52..93297659 100644 --- a/crates/radicle-cli/src/commands/sync.rs +++ b/crates/radicle-cli/src/commands/sync.rs @@ -345,7 +345,7 @@ fn sync_status( const SYMBOL_STATE_UNKNOWN: &str = "•"; let mut table = Table::<5, term::Label>::new(TableOptions::bordered()); - let mut seeds: Vec<_> = node.seeds(rid)?.into(); + let mut seeds: Vec<_> = node.seeds_for(rid, [*profile.did()])?.into(); let local_nid = node.nid()?; let aliases = profile.aliases(); @@ -522,7 +522,7 @@ pub fn fetch( None => { // We push nodes that are in our seed list in attempt to fulfill the // replicas, if needed. - let seeds = node.seeds(rid)?; + let seeds = node.seeds_for(rid, [*profile.did()])?; let (connected, disconnected) = seeds.partition(); let candidates = connected .into_iter() diff --git a/crates/radicle-cli/src/node.rs b/crates/radicle-cli/src/node.rs index 2db7f3fc..3212ecc6 100644 --- a/crates/radicle-cli/src/node.rs +++ b/crates/radicle-cli/src/node.rs @@ -149,7 +149,7 @@ where let config = match sync::PrivateNetwork::private_repo(&doc) { None => { - let (synced, unsynced) = node.seeds(rid)?.iter().fold( + let (synced, unsynced) = node.seeds_for(rid, [*me])?.iter().fold( (BTreeSet::new(), BTreeSet::new()), |(mut synced, mut unsynced), seed| { if seed.is_synced() { diff --git a/crates/radicle-cli/tests/commands.rs b/crates/radicle-cli/tests/commands.rs index 866eaee8..ec133454 100644 --- a/crates/radicle-cli/tests/commands.rs +++ b/crates/radicle-cli/tests/commands.rs @@ -1704,7 +1704,7 @@ fn test_clone_without_seeds() { let working = environment.tempdir().join("working"); let rid = alice.project("heartwood", "Radicle Heartwood Protocol & Stack"); let mut alice = alice.spawn(); - let seeds = alice.handle.seeds(rid).unwrap(); + let seeds = alice.handle.seeds_for(rid, [alice.id]).unwrap(); let connected = seeds.connected().collect::>(); assert!(connected.is_empty()); diff --git a/crates/radicle-node/src/control.rs b/crates/radicle-node/src/control.rs index be61e5a7..0be586b2 100644 --- a/crates/radicle-node/src/control.rs +++ b/crates/radicle-node/src/control.rs @@ -172,11 +172,17 @@ where CommandResult::Okay(addrs).to_writer(writer)?; } + #[allow(deprecated)] Command::Seeds { rid } => { let seeds = handle.seeds(rid)?; CommandResult::Okay(seeds).to_writer(writer)?; } + Command::SeedsFor { rid, namespaces } => { + let seeds = handle.seeds_for(rid, namespaces)?; + + CommandResult::Okay(seeds).to_writer(writer)?; + } Command::Sessions => { let sessions = handle.sessions()?; diff --git a/crates/radicle-node/src/runtime/handle.rs b/crates/radicle-node/src/runtime/handle.rs index bd2e8522..5aefaf83 100644 --- a/crates/radicle-node/src/runtime/handle.rs +++ b/crates/radicle-node/src/runtime/handle.rs @@ -202,9 +202,17 @@ impl radicle::node::Handle for Handle { .map_err(Error::from) } - fn seeds(&mut self, id: RepoId) -> Result { + fn seeds_for( + &mut self, + id: RepoId, + namespaces: impl IntoIterator, + ) -> Result { let (sender, receiver) = chan::bounded(1); - self.command(service::Command::Seeds(id, sender))?; + self.command(service::Command::Seeds( + id, + HashSet::from_iter(namespaces), + sender, + ))?; receiver.recv().map_err(Error::from) } diff --git a/crates/radicle-node/src/test/handle.rs b/crates/radicle-node/src/test/handle.rs index 9fcb70a6..12cb6735 100644 --- a/crates/radicle-node/src/test/handle.rs +++ b/crates/radicle-node/src/test/handle.rs @@ -55,7 +55,11 @@ impl radicle::node::Handle for Handle { unimplemented!(); } - fn seeds(&mut self, _id: RepoId) -> Result { + fn seeds_for( + &mut self, + _id: RepoId, + _namespaces: impl IntoIterator, + ) -> Result { unimplemented!(); } diff --git a/crates/radicle-node/src/test/node.rs b/crates/radicle-node/src/test/node.rs index 95e6f916..0b6ef093 100644 --- a/crates/radicle-node/src/test/node.rs +++ b/crates/radicle-node/src/test/node.rs @@ -200,7 +200,7 @@ impl + cyphernet::Ecdh> NodeHandle { log::debug!(target: "test", "Waiting for {} to be in sync with {nid} for {rid}", self.id); loop { - let seeds = self.handle.seeds(*rid).unwrap(); + let seeds = self.handle.seeds_for(*rid, [self.id]).unwrap(); if seeds.iter().any(|s| s.nid == *nid && s.is_synced()) { break; } diff --git a/crates/radicle-node/src/tests/e2e.rs b/crates/radicle-node/src/tests/e2e.rs index 01e018af..6a8b6807 100644 --- a/crates/radicle-node/src/tests/e2e.rs +++ b/crates/radicle-node/src/tests/e2e.rs @@ -183,7 +183,7 @@ fn test_replication() { let updated = alice.handle.seed(acme, Scope::All).unwrap(); assert!(updated); - let seeds = alice.handle.seeds(acme).unwrap(); + let seeds = alice.handle.seeds_for(acme, None).unwrap(); assert!(seeds.is_connected(&bob.id)); let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap(); @@ -549,7 +549,7 @@ fn test_clone() { transport::local::register(alice.storage.clone()); let _ = alice.handle.seed(acme, Scope::All).unwrap(); - let seeds = alice.handle.seeds(acme).unwrap(); + let seeds = alice.handle.seeds_for(acme, None).unwrap(); assert!(seeds.is_connected(&bob.id)); let result = alice.handle.fetch(acme, bob.id, DEFAULT_TIMEOUT).unwrap(); diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs index b5757122..39ddaa90 100644 --- a/crates/radicle-protocol/src/service.rs +++ b/crates/radicle-protocol/src/service.rs @@ -252,8 +252,9 @@ pub enum Command { Config(chan::Sender), /// Get the node's listen addresses. ListenAddrs(chan::Sender>), - /// Lookup seeds for the given repository in the routing table. - Seeds(RepoId, chan::Sender), + /// Lookup seeds for the given repository in the routing table, and report + /// sync status for given namespaces. + Seeds(RepoId, HashSet, chan::Sender), /// Fetch the given repository from the network. Fetch(RepoId, NodeId, time::Duration, chan::Sender), /// Seed the given repository. @@ -278,7 +279,7 @@ impl fmt::Debug for Command { Self::Disconnect(id) => write!(f, "Disconnect({id})"), Self::Config(_) => write!(f, "Config"), Self::ListenAddrs(_) => write!(f, "ListenAddrs"), - Self::Seeds(id, _) => write!(f, "Seeds({id})"), + Self::Seeds(id, _, _) => write!(f, "Seeds({id})"), Self::Fetch(id, node, _, _) => write!(f, "Fetch({id}, {node})"), Self::Seed(id, scope, _) => write!(f, "Seed({id}, {scope})"), Self::Unseed(id, _) => write!(f, "Unseed({id})"), @@ -880,7 +881,7 @@ where Command::ListenAddrs(resp) => { resp.send(self.listening.clone()).ok(); } - Command::Seeds(rid, resp) => match self.seeds(&rid) { + Command::Seeds(rid, namespaces, resp) => match self.seeds(&rid, namespaces) { Ok(seeds) => { let (connected, disconnected) = seeds.partition(); debug!( @@ -2280,14 +2281,19 @@ where Ok(()) } - fn seeds(&self, rid: &RepoId) -> Result { + fn seeds(&self, rid: &RepoId, namespaces: HashSet) -> Result { let mut seeds = Seeds::new(self.rng.clone()); - // First build a list from peers that have synced our own refs, if any. - // This step is skipped if we don't have the repository yet, or don't have - // our own refs. + // First, build a list of peers that have synced refs for `namespaces`, if any. + // This step is skipped: + // 1. For the repository (and thus all `namespaces`), if it not exist in storage. + // 2. For each `namespace` in `namespaces`, which does not exist in storage. if let Ok(repo) = self.storage.repository(*rid) { - if let Ok(local) = RefsAt::new(&repo, self.node_id()) { + for namespace in namespaces.iter() { + let Ok(local) = RefsAt::new(&repo, *namespace) else { + continue; + }; + for seed in self.db.seeds().seeds_for(rid)? { let seed = seed?; let state = self.sessions.get(&seed.nid).map(|s| s.state.clone()); @@ -2310,7 +2316,7 @@ where // These peers have announced that they seed the repository via an inventory // announcement, but we haven't received any ref announcements from them. for nid in self.db.routing().get(rid)? { - if nid == self.node_id() { + if namespaces.contains(&nid) { continue; } if seeds.contains(&nid) { @@ -2528,7 +2534,7 @@ where if self.storage.contains(&rid)? { continue; } - match self.seeds(&rid) { + match self.seeds(&rid, [self.node_id()].into()) { Ok(seeds) => { if let Some(connected) = NonEmpty::from_vec(seeds.connected().collect()) { for seed in connected { diff --git a/crates/radicle/CHANGELOG.md b/crates/radicle/CHANGELOG.md index cf9a6125..e4a0623e 100644 --- a/crates/radicle/CHANGELOG.md +++ b/crates/radicle/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `radicle::node::Handle::announce_refs_for` now allows specifying for which namespaces changes should be announced. A corresponding enum variant `radicle::node::Command::AnnounceRefsFor` is added. +- `radicle::node::Handle::seeds_for` now allows specifying for which + namespaces sync status should be reported. A corresponding enum variant + `radicle::node::Command::SeedsFor` is added. ### Changed @@ -19,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `radicle::node::Handle::announce_refs` is deprecated in favor of `radicle::node::Handle::announce_refs_for`. +- `radicle::node::Handle::seeds` is deprecated in favor of + `radicle::node::Handle::seeds_for`. ### Removed diff --git a/crates/radicle/src/node.rs b/crates/radicle/src/node.rs index ccd86fcf..59d92943 100644 --- a/crates/radicle/src/node.rs +++ b/crates/radicle/src/node.rs @@ -883,8 +883,21 @@ pub trait Handle: Clone + Sync + Send { ) -> Result; /// Disconnect from a peer. fn disconnect(&mut self, node: NodeId) -> Result<(), Self::Error>; - /// Lookup the seeds of a given repository in the routing table. - fn seeds(&mut self, id: RepoId) -> Result; + + /// Look up the seeds of a given repository in the routing table. + #[deprecated(note = "use `seeds_for` instead")] + fn seeds(&mut self, id: RepoId) -> Result { + self.seeds_for(id, [self.nid()?]) + } + + /// Look up the seeds of a given repository in the routing table + /// and report sync status for `namespaces`. + fn seeds_for( + &mut self, + id: RepoId, + namespaces: impl IntoIterator, + ) -> Result; + /// Fetch a repository from the network. fn fetch( &mut self, @@ -1137,9 +1150,19 @@ impl Handle for Node { Ok(()) } - fn seeds(&mut self, rid: RepoId) -> Result { + fn seeds_for( + &mut self, + rid: RepoId, + namespaces: impl IntoIterator, + ) -> Result { let seeds = self - .call::(Command::Seeds { rid }, DEFAULT_TIMEOUT)? + .call::( + Command::SeedsFor { + rid, + namespaces: HashSet::from_iter(namespaces), + }, + DEFAULT_TIMEOUT, + )? .next() .ok_or(Error::EmptyResponse)??; diff --git a/crates/radicle/src/node/command.rs b/crates/radicle/src/node/command.rs index 0ca5af34..9f57137c 100644 --- a/crates/radicle/src/node/command.rs +++ b/crates/radicle/src/node/command.rs @@ -77,10 +77,27 @@ pub enum Command { nid: NodeId, }, - /// Lookup seeds for the given repository in the routing table. + /// Look up seeds for the given repository in the routing table. #[serde(rename_all = "camelCase")] + #[deprecated(note = "use `SeedsFor` instead")] Seeds { rid: RepoId }, + /// Look up seeds for the given repository in the routing table and + /// report sync status for the given namespaces. + #[serde(rename_all = "camelCase")] + SeedsFor { + /// The ID of the repository for which seeds should be looked up + /// in the routing table. + rid: RepoId, + + /// The namespaces for which references should be announced. + #[cfg_attr( + feature = "schemars", + schemars(with = "HashSet") + )] + namespaces: HashSet, + }, + /// Get the current peer sessions. Sessions,