Skip to content

Commit 3c4cb34

Browse files
feat(kad): add Behaviour::get_record_from
1 parent 3e72d4c commit 3c4cb34

3 files changed

Lines changed: 224 additions & 0 deletions

File tree

protocols/kad/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
- Remove no longer constructed GetRecordError::QuorumFailed.
1010
See [PR 6106](https://github.com/libp2p/rust-libp2p/pull/6106)
1111

12+
- Add `Behaviour::get_record_from`: a seeded `GET` that contacts only a
13+
caller-supplied peer set, mirroring `Behaviour::put_record_to`. This is the
14+
primitive required by S/Kademlia §4.2 node-disjoint record lookups.
15+
See [PR PLACEHOLDER](https://github.com/libp2p/rust-libp2p/pull/PLACEHOLDER).
16+
1217
## 0.48.1
1318

1419
- Implement `Copy` for `QueryStats` and `ProgressStep`

protocols/kad/src/behaviour.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,81 @@ where
846846
id
847847
}
848848

849+
/// Performs a lookup for a record in the DHT, restricting the query to a
850+
/// fixed set of `peers`.
851+
///
852+
/// Unlike [`Behaviour::get_record`], which seeds its query from the local
853+
/// routing table's closest peers and then expands iteratively toward the
854+
/// key, this query contacts **only** the given `peers` and never any node
855+
/// discovered mid-walk. It is the `GET` counterpart of
856+
/// [`Behaviour::put_record_to`]: where `put_record_to` writes to a fixed
857+
/// peer set, `get_record_from` reads from one.
858+
///
859+
/// The local store is consulted exactly as in [`Behaviour::get_record`]: a
860+
/// local hit is emitted immediately as a [`GetRecordOk::FoundRecord`] with
861+
/// `peer` set to `None`.
862+
///
863+
/// The result of this operation is delivered in a
864+
/// [`Event::OutboundQueryProgressed`] with `result` [`QueryResult::GetRecord`].
865+
///
866+
/// > **Note**: Like [`Behaviour::put_record_to`], this is not a regular
867+
/// > Kademlia DHT operation. It deliberately bypasses the iterative
868+
/// > closest-peer walk to operate on a caller-chosen peer set, e.g. for an
869+
/// > S/Kademlia node-disjoint lookup where each of several disjoint peer
870+
/// > groups is queried as an independent, non-converging path.
871+
pub fn get_record_from<I>(&mut self, key: record::Key, peers: I) -> QueryId
872+
where
873+
I: IntoIterator<Item = PeerId>,
874+
{
875+
let record = if let Some(record) = self.store.get(&key) {
876+
if record.is_expired(Instant::now()) {
877+
self.store.remove(&key);
878+
None
879+
} else {
880+
Some(PeerRecord {
881+
peer: None,
882+
record: record.into_owned(),
883+
})
884+
}
885+
} else {
886+
None
887+
};
888+
889+
let step = ProgressStep::first();
890+
891+
let info = if record.is_some() {
892+
QueryInfo::GetRecord {
893+
key,
894+
step: step.next(),
895+
found_a_record: true,
896+
cache_candidates: BTreeMap::new(),
897+
}
898+
} else {
899+
QueryInfo::GetRecord {
900+
key,
901+
step,
902+
found_a_record: false,
903+
cache_candidates: BTreeMap::new(),
904+
}
905+
};
906+
let id = self.queries.add_fixed(peers, info);
907+
908+
// No queries were actually done for the results yet.
909+
let stats = QueryStats::empty();
910+
911+
if let Some(record) = record {
912+
self.queued_events
913+
.push_back(ToSwarm::GenerateEvent(Event::OutboundQueryProgressed {
914+
id,
915+
result: QueryResult::GetRecord(Ok(GetRecordOk::FoundRecord(record))),
916+
step,
917+
stats,
918+
}));
919+
}
920+
921+
id
922+
}
923+
849924
/// Stores a record in the DHT, locally as well as at the nodes
850925
/// closest to the key as per the xor distance metric.
851926
///

protocols/kad/src/behaviour/test.rs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,150 @@ fn get_record() {
881881
}))
882882
}
883883

884+
/// `get_record_from` retrieves the record from a seeded peer that holds it.
885+
///
886+
/// node 0 knows both node 1 and node 2, but the query is seeded with node 2
887+
/// only. Because `FixedPeersIter` never expands the peer set, the record is
888+
/// returned from node 2.
889+
#[test]
890+
fn get_record_from_seeded_peer() {
891+
let mut swarms = build_nodes(3);
892+
893+
// node 0 learns the addresses of node 1 and node 2.
894+
let node1_id = *swarms[1].1.local_peer_id();
895+
let node2_id = *swarms[2].1.local_peer_id();
896+
let node1_addr = swarms[1].0.clone();
897+
let node2_addr = swarms[2].0.clone();
898+
swarms[0]
899+
.1
900+
.behaviour_mut()
901+
.add_address(&node1_id, node1_addr);
902+
swarms[0]
903+
.1
904+
.behaviour_mut()
905+
.add_address(&node2_id, node2_addr);
906+
907+
// Drop the swarm addresses.
908+
let mut swarms = swarms
909+
.into_iter()
910+
.map(|(_addr, swarm)| swarm)
911+
.collect::<Vec<_>>();
912+
913+
let record = Record::new(random_multihash(), vec![4, 5, 6]);
914+
swarms[2].behaviour_mut().store.put(record.clone()).unwrap();
915+
916+
// Seed the GET to node 2 only.
917+
let qid = swarms[0]
918+
.behaviour_mut()
919+
.get_record_from(record.key.clone(), [node2_id]);
920+
921+
let rt = Runtime::new().unwrap();
922+
rt.block_on(poll_fn(move |ctx| {
923+
for swarm in &mut swarms {
924+
loop {
925+
match swarm.poll_next_unpin(ctx) {
926+
Poll::Ready(Some(SwarmEvent::Behaviour(Event::OutboundQueryProgressed {
927+
id,
928+
result: QueryResult::GetRecord(Ok(GetRecordOk::FoundRecord(r))),
929+
..
930+
}))) => {
931+
assert_eq!(id, qid);
932+
assert_eq!(r.record, record);
933+
return Poll::Ready(());
934+
}
935+
// Ignore any other event.
936+
Poll::Ready(Some(_)) => (),
937+
e @ Poll::Ready(_) => panic!("Unexpected return value: {e:?}"),
938+
Poll::Pending => break,
939+
}
940+
}
941+
}
942+
943+
Poll::Pending
944+
}))
945+
}
946+
947+
/// `get_record_from` never expands beyond the seeded peer set.
948+
///
949+
/// node 0 knows both node 1 and node 2 and the record lives on node 2, but the
950+
/// query is seeded with node 1 only. An iterative `get_record` would reach
951+
/// node 2 (node 0 knows it directly) and find the record; `get_record_from`
952+
/// seeded with node 1 must instead finish with `NotFound`, never contacting
953+
/// node 2 — the property S/Kademlia node-disjoint lookups rely on.
954+
#[test]
955+
fn get_record_from_does_not_expand_beyond_seeded_peers() {
956+
let mut swarms = build_nodes(3);
957+
958+
let node1_id = *swarms[1].1.local_peer_id();
959+
let node2_id = *swarms[2].1.local_peer_id();
960+
let node1_addr = swarms[1].0.clone();
961+
let node2_addr = swarms[2].0.clone();
962+
swarms[0]
963+
.1
964+
.behaviour_mut()
965+
.add_address(&node1_id, node1_addr);
966+
swarms[0]
967+
.1
968+
.behaviour_mut()
969+
.add_address(&node2_id, node2_addr);
970+
971+
// Drop the swarm addresses.
972+
let mut swarms = swarms
973+
.into_iter()
974+
.map(|(_addr, swarm)| swarm)
975+
.collect::<Vec<_>>();
976+
977+
// The record lives on node 2, which is NOT in the seeded set.
978+
let record = Record::new(random_multihash(), vec![7, 8, 9]);
979+
let key = record.key.clone();
980+
swarms[2].behaviour_mut().store.put(record).unwrap();
981+
982+
// Seed the GET to node 1 only — node 1 does not hold the record.
983+
let qid = swarms[0]
984+
.behaviour_mut()
985+
.get_record_from(key.clone(), [node1_id]);
986+
987+
let rt = Runtime::new().unwrap();
988+
rt.block_on(poll_fn(move |ctx| {
989+
for swarm in &mut swarms {
990+
loop {
991+
match swarm.poll_next_unpin(ctx) {
992+
Poll::Ready(Some(SwarmEvent::Behaviour(Event::OutboundQueryProgressed {
993+
id,
994+
result:
995+
QueryResult::GetRecord(Err(GetRecordError::NotFound {
996+
key: k,
997+
closest_peers,
998+
})),
999+
..
1000+
}))) => {
1001+
assert_eq!(id, qid);
1002+
assert_eq!(k, key);
1003+
// The query must never have expanded to node 2.
1004+
assert!(
1005+
!closest_peers.contains(&node2_id),
1006+
"fixed-peer GET must not contact peers outside the seeded set"
1007+
);
1008+
return Poll::Ready(());
1009+
}
1010+
Poll::Ready(Some(SwarmEvent::Behaviour(Event::OutboundQueryProgressed {
1011+
result: QueryResult::GetRecord(Ok(GetRecordOk::FoundRecord(_))),
1012+
..
1013+
}))) => {
1014+
panic!("record lives only on the un-seeded node 2 and must not be found")
1015+
}
1016+
// Ignore any other event.
1017+
Poll::Ready(Some(_)) => (),
1018+
e @ Poll::Ready(_) => panic!("Unexpected return value: {e:?}"),
1019+
Poll::Pending => break,
1020+
}
1021+
}
1022+
}
1023+
1024+
Poll::Pending
1025+
}))
1026+
}
1027+
8841028
#[test]
8851029
fn get_record_many() {
8861030
// TODO: Randomise

0 commit comments

Comments
 (0)