feat: improve metadata peer discovery
This commit is contained in:
@@ -177,6 +177,9 @@ hash ingress → deduplicating Metadata queue → bounded workers → torrent ca
|
||||
| `metadata.max_worker_count` | `256` | 最大并发 Metadata job 数 |
|
||||
| `metadata.peer_failure_cache_capacity` | `200000` | 坏 Peer 缓存容量 |
|
||||
| `metadata.peer_failure_ttl_secs` | `60` | 坏 Peer 缓存 TTL |
|
||||
| `peer_lookup.max_lookups_per_second` | `32` | 每秒启动的主动 `get_peers` 查询数;`0` 为关闭 |
|
||||
| `peer_lookup.burst` | `32` | 空闲后可立即消费的查询预算 |
|
||||
| `peer_lookup.max_active_lookups` | `64` | 同时活跃的 InfoHash 查询上限 |
|
||||
|
||||
### `crawl.rate_limit`
|
||||
|
||||
@@ -328,7 +331,7 @@ PrometheusBuilder::new()
|
||||
## 应用层集成
|
||||
|
||||
本 crate 只提供 DHT、BEP-9 Metadata、回调和观测能力,不包含 Redis、Manticore、
|
||||
HTTP 看板或 `P1`~`P16` 性能档位。同级的 `dht-crawler-node` 项目负责这些应用层策略,
|
||||
HTTP 看板或 `P1`~`P17` 性能档位。同级的 `dht-crawler-node` 项目负责这些应用层策略,
|
||||
并把档位转换成具体的 `DHTOptions`。开发两个项目时应保持下面的目录关系:
|
||||
|
||||
```text
|
||||
|
||||
@@ -913,6 +913,7 @@ mod tests {
|
||||
id: None,
|
||||
nodes: None,
|
||||
nodes6: None,
|
||||
values: None,
|
||||
};
|
||||
engine.route_response("8.8.8.8:1".parse().unwrap(), [1; 8], response());
|
||||
engine.route_response("1.1.1.1:2".parse().unwrap(), [2; 8], response());
|
||||
|
||||
+85
-1
@@ -1,4 +1,4 @@
|
||||
use crate::addr::is_valid_node_addr;
|
||||
use crate::addr::{addr_allowed_by_netmode, is_valid_node_addr};
|
||||
use crate::node_id::TransactionId;
|
||||
use crate::protocol::DhtResponse;
|
||||
use crate::types::{NetMode, NodeTuple};
|
||||
@@ -50,6 +50,40 @@ pub(crate) fn for_each_response_node(
|
||||
count
|
||||
}
|
||||
|
||||
pub(crate) fn for_each_response_peer(
|
||||
response: &DhtResponse,
|
||||
netmode: NetMode,
|
||||
mut visit: impl FnMut(SocketAddr),
|
||||
) -> usize {
|
||||
let mut count = 0;
|
||||
let Some(values) = response.values.as_ref() else {
|
||||
return count;
|
||||
};
|
||||
for value in values {
|
||||
let bytes = value.as_ref();
|
||||
let addr = match bytes.len() {
|
||||
6 => {
|
||||
let ip = Ipv4Addr::new(bytes[0], bytes[1], bytes[2], bytes[3]);
|
||||
let port = u16::from_be_bytes([bytes[4], bytes[5]]);
|
||||
SocketAddr::new(IpAddr::V4(ip), port)
|
||||
}
|
||||
18 => {
|
||||
let ip_bytes: [u8; 16] = bytes[..16]
|
||||
.try_into()
|
||||
.expect("compact IPv6 Peer address is 16 bytes");
|
||||
let port = u16::from_be_bytes([bytes[16], bytes[17]]);
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip_bytes)), port)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if addr_allowed_by_netmode(&addr, netmode) && is_valid_node_addr(&addr) {
|
||||
visit(addr);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
pub(crate) fn encode_find_node_query(
|
||||
buffer: &mut BytesMut,
|
||||
tid: &TransactionId,
|
||||
@@ -67,6 +101,23 @@ pub(crate) fn encode_find_node_query(
|
||||
buffer.extend_from_slice(b"1:y1:qe");
|
||||
}
|
||||
|
||||
pub(crate) fn encode_get_peers_query(
|
||||
buffer: &mut BytesMut,
|
||||
tid: &TransactionId,
|
||||
info_hash: &[u8; 20],
|
||||
sender_id: &[u8; 20],
|
||||
) {
|
||||
buffer.clear();
|
||||
buffer.reserve(111);
|
||||
buffer.extend_from_slice(b"d1:ad2:id20:");
|
||||
buffer.extend_from_slice(sender_id);
|
||||
buffer.extend_from_slice(b"9:info_hash20:");
|
||||
buffer.extend_from_slice(info_hash);
|
||||
buffer.extend_from_slice(b"e1:q9:get_peers1:t8:");
|
||||
buffer.extend_from_slice(tid);
|
||||
buffer.extend_from_slice(b"1:y1:qe");
|
||||
}
|
||||
|
||||
pub(crate) fn encode_response(
|
||||
buffer: &mut BytesMut,
|
||||
tid: &[u8],
|
||||
@@ -176,6 +227,19 @@ mod tests {
|
||||
assert_eq!(message.a.unwrap().target.unwrap().as_ref(), &target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_get_peers_encoding_round_trips() {
|
||||
let mut buffer = BytesMut::new();
|
||||
let tid = [1; 8];
|
||||
let info_hash = [2; 20];
|
||||
let sender = [3; 20];
|
||||
encode_get_peers_query(&mut buffer, &tid, &info_hash, &sender);
|
||||
let message: DhtMessage = serde_bencode::from_bytes(&buffer).unwrap();
|
||||
assert_eq!(message.t.as_ref(), &tid);
|
||||
assert_eq!(message.q.as_deref(), Some("get_peers"));
|
||||
assert_eq!(message.a.unwrap().info_hash.unwrap().as_ref(), &info_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_response_encoding_round_trips() {
|
||||
let mut buffer = BytesMut::new();
|
||||
@@ -188,4 +252,24 @@ mod tests {
|
||||
let response = message.r.unwrap();
|
||||
assert_eq!(response.nodes.unwrap().len(), 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_get_peers_values_are_validated() {
|
||||
let response = DhtResponse {
|
||||
id: None,
|
||||
nodes: None,
|
||||
nodes6: None,
|
||||
values: Some(vec![
|
||||
serde_bytes::ByteBuf::from(vec![8, 8, 8, 8, 0x1a, 0xe1]),
|
||||
serde_bytes::ByteBuf::from(vec![10, 0, 0, 1, 0x1a, 0xe1]),
|
||||
serde_bytes::ByteBuf::from(vec![1, 2, 3]),
|
||||
]),
|
||||
};
|
||||
let mut peers = Vec::new();
|
||||
assert_eq!(
|
||||
for_each_response_peer(&response, NetMode::Ipv4Only, |peer| peers.push(peer)),
|
||||
1
|
||||
);
|
||||
assert_eq!(peers[0], "8.8.8.8:6881".parse().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -16,6 +16,7 @@ mod krpc;
|
||||
pub mod metadata;
|
||||
mod node_id;
|
||||
mod node_pool;
|
||||
mod peer_lookup;
|
||||
/// Serializable BEP-5 KRPC wire types.
|
||||
pub mod protocol;
|
||||
mod routing_snapshot;
|
||||
@@ -36,8 +37,8 @@ pub use scheduler::{MetadataScheduler, MetadataSchedulerCallbacks, MetadataSched
|
||||
pub use server::{DHTServer, HashDiscovered};
|
||||
pub use types::{
|
||||
BootstrapOptions, CrawlOptions, DHTOptions, FileInfo, MetadataFetchCompletion,
|
||||
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PoolOptions,
|
||||
RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
|
||||
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PeerLookupOptions,
|
||||
PoolOptions, RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
|
||||
};
|
||||
|
||||
/// Common server, configuration and callback payload imports.
|
||||
@@ -50,8 +51,8 @@ pub mod prelude {
|
||||
pub use crate::server::DHTServer;
|
||||
pub use crate::types::{
|
||||
BootstrapOptions, CrawlOptions, DHTOptions, FileInfo, MetadataFetchCompletion,
|
||||
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PoolOptions,
|
||||
RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
|
||||
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PeerLookupOptions,
|
||||
PoolOptions, RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -194,11 +194,25 @@ impl RbitFetcher {
|
||||
/// The deadline covers TCP connect, both BitTorrent handshakes, all metadata
|
||||
/// piece I/O, hash validation and bencode parsing. Inner library timeouts can
|
||||
/// therefore never stack on top of the configured metadata timeout.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn fetch(
|
||||
&self,
|
||||
info_hash: &[u8; 20],
|
||||
peer_addr: SocketAddr,
|
||||
) -> MetadataFetchOutcome {
|
||||
self.fetch_with_attempt_observer(info_hash, peer_addr, || {})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_with_attempt_observer<F>(
|
||||
&self,
|
||||
info_hash: &[u8; 20],
|
||||
peer_addr: SocketAddr,
|
||||
on_attempt: F,
|
||||
) -> MetadataFetchOutcome
|
||||
where
|
||||
F: FnOnce() + Send,
|
||||
{
|
||||
let (cached_reason, cache_entries) = self.peer_failure_cache.get(peer_addr, Instant::now());
|
||||
self.set_peer_failure_cache_entries(cache_entries);
|
||||
if let Some(reason) = cached_reason {
|
||||
@@ -215,6 +229,7 @@ impl RbitFetcher {
|
||||
return MetadataFetchOutcome::SkippedCached;
|
||||
}
|
||||
|
||||
on_attempt();
|
||||
self.runtime_stats.metadata_peer_attempt();
|
||||
#[cfg(feature = "metrics")]
|
||||
{
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
use crate::budget::RateBucket;
|
||||
use crate::krpc::{encode_get_peers_query, for_each_response_node, for_each_response_peer};
|
||||
use crate::node_id::TransactionId;
|
||||
use crate::protocol::DhtResponse;
|
||||
use crate::routing_snapshot::{RoutingSnapshot, xor_distance_cmp};
|
||||
use crate::runtime_stats::DhtRuntimeStats;
|
||||
use crate::server::HashDiscovered;
|
||||
use crate::types::{NetMode, NodeTuple, PeerLookupOptions};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use arc_swap::ArcSwap;
|
||||
use bytes::BytesMut;
|
||||
#[cfg(feature = "metrics")]
|
||||
use metrics::counter;
|
||||
use std::collections::VecDeque;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const LOOKUP_TID_TAG: u8 = 0xa5;
|
||||
const MAX_QUERIES_PER_LOOKUP: usize = 12;
|
||||
const MAX_CONCURRENT_QUERIES_PER_LOOKUP: usize = 4;
|
||||
const MAX_FRONTIER_NODES: usize = 64;
|
||||
const MAX_PEERS_PER_LOOKUP: usize = 12;
|
||||
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
const MAINTENANCE_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const REQUEST_CHANNEL_CAPACITY: usize = 1_024;
|
||||
const RESPONSE_CHANNEL_CAPACITY: usize = 4_096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
||||
struct PendingKey {
|
||||
addr: SocketAddr,
|
||||
tid: TransactionId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct PendingQuery {
|
||||
lookup_id: u64,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
struct LookupState {
|
||||
info_hash: [u8; 20],
|
||||
info_hash_hex: String,
|
||||
frontier: Vec<NodeTuple>,
|
||||
seen_nodes: AHashSet<SocketAddr>,
|
||||
peers: AHashSet<SocketAddr>,
|
||||
queried: usize,
|
||||
outstanding: usize,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl LookupState {
|
||||
fn pop_closest(&mut self) -> Option<NodeTuple> {
|
||||
let index = self
|
||||
.frontier
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by(|(_, left), (_, right)| xor_distance_cmp(&left.id, &right.id, &self.info_hash))
|
||||
.map(|(index, _)| index)?;
|
||||
Some(self.frontier.swap_remove(index))
|
||||
}
|
||||
|
||||
fn is_complete(&self, now: Instant) -> bool {
|
||||
self.deadline <= now
|
||||
|| self.peers.len() >= MAX_PEERS_PER_LOOKUP
|
||||
|| (self.outstanding == 0
|
||||
&& (self.queried >= MAX_QUERIES_PER_LOOKUP || self.frontier.is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
struct LookupResponse {
|
||||
remote_addr: SocketAddr,
|
||||
tid: TransactionId,
|
||||
response: DhtResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PeerLookupHandle {
|
||||
request_tx: mpsc::Sender<[u8; 20]>,
|
||||
response_tx: mpsc::Sender<LookupResponse>,
|
||||
runtime_stats: DhtRuntimeStats,
|
||||
}
|
||||
|
||||
pub(crate) struct PeerLookupRuntime {
|
||||
pub(crate) options: PeerLookupOptions,
|
||||
pub(crate) stats: DhtRuntimeStats,
|
||||
pub(crate) shutdown: CancellationToken,
|
||||
}
|
||||
|
||||
impl PeerLookupHandle {
|
||||
pub(crate) fn request_sender(&self) -> mpsc::Sender<[u8; 20]> {
|
||||
self.request_tx.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn route_response(
|
||||
&self,
|
||||
remote_addr: SocketAddr,
|
||||
tid: TransactionId,
|
||||
response: DhtResponse,
|
||||
) {
|
||||
if self
|
||||
.response_tx
|
||||
.try_send(LookupResponse {
|
||||
remote_addr,
|
||||
tid,
|
||||
response,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
self.runtime_stats.peer_lookup_response_dropped();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_dropped_total", "reason" => "response_queue_full")
|
||||
.increment(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_peer_lookup_tid(tid: &TransactionId) -> bool {
|
||||
tid[0] == LOOKUP_TID_TAG
|
||||
}
|
||||
|
||||
pub(crate) fn spawn_peer_lookup(
|
||||
netmode: NetMode,
|
||||
local_id: [u8; 20],
|
||||
sockets: &std::collections::HashMap<SocketAddr, Arc<UdpSocket>>,
|
||||
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
|
||||
hash_tx: mpsc::Sender<HashDiscovered>,
|
||||
runtime: PeerLookupRuntime,
|
||||
) -> PeerLookupHandle {
|
||||
let PeerLookupRuntime {
|
||||
options,
|
||||
stats,
|
||||
shutdown,
|
||||
} = runtime;
|
||||
let (request_tx, request_rx) = mpsc::channel(REQUEST_CHANNEL_CAPACITY);
|
||||
let (response_tx, response_rx) = mpsc::channel(RESPONSE_CHANNEL_CAPACITY);
|
||||
let socket_v4 = sockets
|
||||
.iter()
|
||||
.find_map(|(addr, socket)| addr.is_ipv4().then(|| socket.clone()));
|
||||
let socket_v6 = sockets
|
||||
.iter()
|
||||
.find_map(|(addr, socket)| addr.is_ipv6().then(|| socket.clone()));
|
||||
let actor = PeerLookupActor {
|
||||
netmode,
|
||||
local_id,
|
||||
socket_v4,
|
||||
socket_v6,
|
||||
snapshot,
|
||||
hash_tx,
|
||||
request_rx,
|
||||
response_rx,
|
||||
request_budget: RateBucket::per_second(
|
||||
options.max_lookups_per_second,
|
||||
options.burst,
|
||||
true,
|
||||
Instant::now(),
|
||||
),
|
||||
max_active_lookups: options.max_active_lookups,
|
||||
active: AHashMap::new(),
|
||||
pending: AHashMap::new(),
|
||||
pending_expiry: VecDeque::new(),
|
||||
next_lookup_id: 1,
|
||||
next_tid: 1,
|
||||
runtime_stats: stats.clone(),
|
||||
shutdown,
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
PeerLookupHandle {
|
||||
request_tx,
|
||||
response_tx,
|
||||
runtime_stats: stats,
|
||||
}
|
||||
}
|
||||
|
||||
struct PeerLookupActor {
|
||||
netmode: NetMode,
|
||||
local_id: [u8; 20],
|
||||
socket_v4: Option<Arc<UdpSocket>>,
|
||||
socket_v6: Option<Arc<UdpSocket>>,
|
||||
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
|
||||
hash_tx: mpsc::Sender<HashDiscovered>,
|
||||
request_rx: mpsc::Receiver<[u8; 20]>,
|
||||
response_rx: mpsc::Receiver<LookupResponse>,
|
||||
request_budget: RateBucket,
|
||||
max_active_lookups: usize,
|
||||
active: AHashMap<u64, LookupState>,
|
||||
pending: AHashMap<PendingKey, PendingQuery>,
|
||||
pending_expiry: VecDeque<(Instant, PendingKey)>,
|
||||
next_lookup_id: u64,
|
||||
next_tid: u64,
|
||||
runtime_stats: DhtRuntimeStats,
|
||||
shutdown: CancellationToken,
|
||||
}
|
||||
|
||||
impl PeerLookupActor {
|
||||
async fn run(mut self) {
|
||||
let mut maintenance = tokio::time::interval(MAINTENANCE_INTERVAL);
|
||||
maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.shutdown.cancelled() => break,
|
||||
response = self.response_rx.recv() => {
|
||||
let Some(response) = response else { break };
|
||||
self.handle_response(response, Instant::now()).await;
|
||||
}
|
||||
_ = maintenance.tick() => self.expire(Instant::now()).await,
|
||||
request = self.request_rx.recv() => {
|
||||
let Some(info_hash) = request else { break };
|
||||
self.start_lookup(info_hash, Instant::now()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_lookup(&mut self, info_hash: [u8; 20], now: Instant) {
|
||||
self.runtime_stats.peer_lookup_requested();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_requests_total").increment(1);
|
||||
if self.active.len() >= self.max_active_lookups || !self.request_budget.try_take_one(now) {
|
||||
self.runtime_stats.peer_lookup_rate_limited();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_dropped_total", "reason" => "rate_limit").increment(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let filter_ipv6 = match (self.socket_v4.is_some(), self.socket_v6.is_some()) {
|
||||
(true, false) => Some(false),
|
||||
(false, true) => Some(true),
|
||||
_ => None,
|
||||
};
|
||||
let frontier =
|
||||
self.snapshot
|
||||
.load()
|
||||
.closest_nodes(&info_hash, MAX_QUERIES_PER_LOOKUP, filter_ipv6);
|
||||
if frontier.is_empty() {
|
||||
self.runtime_stats.peer_lookup_empty();
|
||||
return;
|
||||
}
|
||||
|
||||
let lookup_id = self.next_lookup_id;
|
||||
self.next_lookup_id = self.next_lookup_id.wrapping_add(1).max(1);
|
||||
let seen_nodes = frontier.iter().map(|node| node.addr).collect();
|
||||
self.active.insert(
|
||||
lookup_id,
|
||||
LookupState {
|
||||
info_hash,
|
||||
info_hash_hex: hex::encode(info_hash),
|
||||
frontier,
|
||||
seen_nodes,
|
||||
peers: AHashSet::new(),
|
||||
queried: 0,
|
||||
outstanding: 0,
|
||||
deadline: now + LOOKUP_TIMEOUT,
|
||||
},
|
||||
);
|
||||
self.runtime_stats.peer_lookup_started();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_started_total").increment(1);
|
||||
self.dispatch_more(lookup_id, now).await;
|
||||
}
|
||||
|
||||
async fn dispatch_more(&mut self, lookup_id: u64, now: Instant) {
|
||||
loop {
|
||||
let Some((node, info_hash)) = self.active.get_mut(&lookup_id).and_then(|state| {
|
||||
if state.deadline <= now
|
||||
|| state.peers.len() >= MAX_PEERS_PER_LOOKUP
|
||||
|| state.queried >= MAX_QUERIES_PER_LOOKUP
|
||||
|| state.outstanding >= MAX_CONCURRENT_QUERIES_PER_LOOKUP
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let node = state.pop_closest()?;
|
||||
state.queried += 1;
|
||||
Some((node, state.info_hash))
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let tid = self.next_transaction_id();
|
||||
let key = PendingKey {
|
||||
addr: node.addr,
|
||||
tid,
|
||||
};
|
||||
let mut buffer = BytesMut::with_capacity(128);
|
||||
encode_get_peers_query(&mut buffer, &tid, &info_hash, &self.local_id);
|
||||
let socket = if node.addr.is_ipv4() {
|
||||
self.socket_v4.clone()
|
||||
} else {
|
||||
self.socket_v6.clone()
|
||||
};
|
||||
let sent = match socket {
|
||||
Some(socket) => socket.send_to(&buffer, node.addr).await.is_ok(),
|
||||
None => false,
|
||||
};
|
||||
if !sent {
|
||||
self.runtime_stats.peer_lookup_send_failed();
|
||||
continue;
|
||||
}
|
||||
|
||||
let deadline = now + QUERY_TIMEOUT;
|
||||
self.pending.insert(
|
||||
key,
|
||||
PendingQuery {
|
||||
lookup_id,
|
||||
deadline,
|
||||
},
|
||||
);
|
||||
self.pending_expiry.push_back((deadline, key));
|
||||
if let Some(state) = self.active.get_mut(&lookup_id) {
|
||||
state.outstanding += 1;
|
||||
}
|
||||
self.runtime_stats.udp_sent(buffer.len());
|
||||
self.runtime_stats.peer_lookup_query();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_queries_total").increment(1);
|
||||
}
|
||||
self.finish_if_complete(lookup_id, now);
|
||||
}
|
||||
|
||||
async fn handle_response(&mut self, event: LookupResponse, now: Instant) {
|
||||
let key = PendingKey {
|
||||
addr: event.remote_addr,
|
||||
tid: event.tid,
|
||||
};
|
||||
let Some(pending) = self.pending.remove(&key) else {
|
||||
return;
|
||||
};
|
||||
let Some(state) = self.active.get_mut(&pending.lookup_id) else {
|
||||
return;
|
||||
};
|
||||
state.outstanding = state.outstanding.saturating_sub(1);
|
||||
self.runtime_stats.peer_lookup_response();
|
||||
|
||||
let mut discovered = Vec::new();
|
||||
for_each_response_peer(&event.response, self.netmode, |peer| {
|
||||
if state.peers.len() < MAX_PEERS_PER_LOOKUP && state.peers.insert(peer) {
|
||||
discovered.push(peer);
|
||||
}
|
||||
});
|
||||
let mut response_nodes = Vec::new();
|
||||
for_each_response_node(&event.response, self.netmode, |node| {
|
||||
response_nodes.push(node)
|
||||
});
|
||||
for node in response_nodes {
|
||||
if state.frontier.len() >= MAX_FRONTIER_NODES {
|
||||
break;
|
||||
}
|
||||
if state.seen_nodes.insert(node.addr) {
|
||||
state.frontier.push(node);
|
||||
}
|
||||
}
|
||||
let hash = state.info_hash_hex.clone();
|
||||
let lookup_id = pending.lookup_id;
|
||||
let _ = state;
|
||||
|
||||
for peer in discovered {
|
||||
let event = HashDiscovered {
|
||||
info_hash: hash.clone(),
|
||||
peer_addr: peer,
|
||||
discovered_at: now,
|
||||
};
|
||||
if self.hash_tx.try_send(event).is_ok() {
|
||||
self.runtime_stats.peer_lookup_peer_found();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_peers_found_total").increment(1);
|
||||
} else {
|
||||
self.runtime_stats.peer_lookup_output_dropped();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_peer_lookup_dropped_total", "reason" => "hash_queue_full")
|
||||
.increment(1);
|
||||
}
|
||||
}
|
||||
self.dispatch_more(lookup_id, now).await;
|
||||
}
|
||||
|
||||
async fn expire(&mut self, now: Instant) {
|
||||
let mut affected = AHashSet::new();
|
||||
while let Some((deadline, key)) = self.pending_expiry.front().copied() {
|
||||
if deadline > now {
|
||||
break;
|
||||
}
|
||||
self.pending_expiry.pop_front();
|
||||
let should_remove = self
|
||||
.pending
|
||||
.get(&key)
|
||||
.is_some_and(|pending| pending.deadline == deadline);
|
||||
if !should_remove {
|
||||
continue;
|
||||
}
|
||||
let pending = self.pending.remove(&key).expect("pending lookup exists");
|
||||
if let Some(state) = self.active.get_mut(&pending.lookup_id) {
|
||||
state.outstanding = state.outstanding.saturating_sub(1);
|
||||
affected.insert(pending.lookup_id);
|
||||
}
|
||||
self.runtime_stats.peer_lookup_timeout();
|
||||
}
|
||||
for lookup_id in affected {
|
||||
self.dispatch_more(lookup_id, now).await;
|
||||
}
|
||||
let expired: Vec<_> = self
|
||||
.active
|
||||
.iter()
|
||||
.filter_map(|(lookup_id, state)| (state.deadline <= now).then_some(*lookup_id))
|
||||
.collect();
|
||||
for lookup_id in expired {
|
||||
self.finish_lookup(lookup_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_if_complete(&mut self, lookup_id: u64, now: Instant) {
|
||||
if self
|
||||
.active
|
||||
.get(&lookup_id)
|
||||
.is_some_and(|state| state.is_complete(now))
|
||||
{
|
||||
self.finish_lookup(lookup_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_lookup(&mut self, lookup_id: u64) {
|
||||
self.active.remove(&lookup_id);
|
||||
self.pending
|
||||
.retain(|_, pending| pending.lookup_id != lookup_id);
|
||||
}
|
||||
|
||||
fn next_transaction_id(&mut self) -> TransactionId {
|
||||
let mut tid = self.next_tid.to_be_bytes();
|
||||
tid[0] = LOOKUP_TID_TAG;
|
||||
self.next_tid = self.next_tid.wrapping_add(1).max(1);
|
||||
tid
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::DhtMessage;
|
||||
|
||||
#[test]
|
||||
fn lookup_transaction_ids_have_a_reserved_tag() {
|
||||
let tid = [LOOKUP_TID_TAG, 1, 2, 3, 4, 5, 6, 7];
|
||||
assert!(is_peer_lookup_tid(&tid));
|
||||
assert!(!is_peer_lookup_tid(&[0; 8]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lookup_response_feeds_discovered_peer_back_to_metadata_scheduler() {
|
||||
let local_socket = Arc::new(UdpSocket::bind("127.0.0.1:0").await.unwrap());
|
||||
let remote_socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let remote_addr = remote_socket.local_addr().unwrap();
|
||||
let local_addr = local_socket.local_addr().unwrap();
|
||||
let sockets = std::collections::HashMap::from([(local_addr, local_socket)]);
|
||||
let snapshot = Arc::new(ArcSwap::from_pointee(RoutingSnapshot::from_nodes(
|
||||
vec![NodeTuple {
|
||||
id: [9; 20],
|
||||
addr: remote_addr,
|
||||
}],
|
||||
1,
|
||||
)));
|
||||
let (hash_tx, mut hash_rx) = mpsc::channel(4);
|
||||
let stats = DhtRuntimeStats::default();
|
||||
let shutdown = CancellationToken::new();
|
||||
let handle = spawn_peer_lookup(
|
||||
NetMode::Ipv4Only,
|
||||
[7; 20],
|
||||
&sockets,
|
||||
snapshot,
|
||||
hash_tx,
|
||||
PeerLookupRuntime {
|
||||
options: PeerLookupOptions::default(),
|
||||
stats: stats.clone(),
|
||||
shutdown: shutdown.clone(),
|
||||
},
|
||||
);
|
||||
handle.request_tx.send([3; 20]).await.unwrap();
|
||||
|
||||
let mut buffer = [0u8; 512];
|
||||
let (len, source) =
|
||||
tokio::time::timeout(Duration::from_secs(1), remote_socket.recv_from(&mut buffer))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(source, local_addr);
|
||||
let query: DhtMessage = serde_bencode::from_bytes(&buffer[..len]).unwrap();
|
||||
assert_eq!(query.q.as_deref(), Some("get_peers"));
|
||||
let tid: TransactionId = query.t.as_ref().try_into().unwrap();
|
||||
handle.route_response(
|
||||
remote_addr,
|
||||
tid,
|
||||
DhtResponse {
|
||||
id: Some(serde_bytes::ByteBuf::from(vec![9; 20])),
|
||||
nodes: None,
|
||||
nodes6: None,
|
||||
values: Some(vec![serde_bytes::ByteBuf::from(vec![
|
||||
8, 8, 4, 4, 0x1a, 0xe1,
|
||||
])]),
|
||||
},
|
||||
);
|
||||
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), hash_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(event.info_hash, hex::encode([3; 20]));
|
||||
assert_eq!(event.peer_addr, "8.8.4.4:6881".parse().unwrap());
|
||||
let snapshot = stats.snapshot();
|
||||
assert_eq!(snapshot.peer_lookup_started, 1);
|
||||
assert_eq!(snapshot.peer_lookup_queries, 1);
|
||||
assert_eq!(snapshot.peer_lookup_responses, 1);
|
||||
assert_eq!(snapshot.peer_lookup_peers_found, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
}
|
||||
@@ -48,4 +48,7 @@ pub struct DhtResponse {
|
||||
#[serde(default)]
|
||||
/// Compact IPv6 node tuples.
|
||||
pub nodes6: Option<serde_bytes::ByteBuf>,
|
||||
#[serde(default)]
|
||||
/// Compact Peer endpoints returned by `get_peers`.
|
||||
pub values: Option<Vec<serde_bytes::ByteBuf>>,
|
||||
}
|
||||
|
||||
@@ -34,4 +34,74 @@ impl RoutingSnapshot {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn closest_nodes(
|
||||
&self,
|
||||
target: &[u8; 20],
|
||||
count: usize,
|
||||
filter_ipv6: Option<bool>,
|
||||
) -> Vec<NodeTuple> {
|
||||
if count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut nodes = match filter_ipv6 {
|
||||
Some(true) => self.v6.clone(),
|
||||
Some(false) => self.v4.clone(),
|
||||
None => {
|
||||
let mut all = Vec::with_capacity(self.v4.len() + self.v6.len());
|
||||
all.extend_from_slice(&self.v4);
|
||||
all.extend_from_slice(&self.v6);
|
||||
all
|
||||
}
|
||||
};
|
||||
let compare =
|
||||
|left: &NodeTuple, right: &NodeTuple| xor_distance_cmp(&left.id, &right.id, target);
|
||||
if nodes.len() > count {
|
||||
nodes.select_nth_unstable_by(count, compare);
|
||||
nodes.truncate(count);
|
||||
}
|
||||
nodes.sort_unstable_by(compare);
|
||||
nodes
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn xor_distance_cmp(
|
||||
left: &[u8; 20],
|
||||
right: &[u8; 20],
|
||||
target: &[u8; 20],
|
||||
) -> std::cmp::Ordering {
|
||||
for index in 0..20 {
|
||||
let ordering = (left[index] ^ target[index]).cmp(&(right[index] ^ target[index]));
|
||||
if ordering != std::cmp::Ordering::Equal {
|
||||
return ordering;
|
||||
}
|
||||
}
|
||||
std::cmp::Ordering::Equal
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
fn node(id: u8, port: u16) -> NodeTuple {
|
||||
NodeTuple {
|
||||
id: [id; 20],
|
||||
addr: SocketAddr::from(([8, 8, 8, 8], port)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_nodes_orders_by_xor_distance_and_limits_results() {
|
||||
let snapshot =
|
||||
RoutingSnapshot::from_nodes(vec![node(0xf0, 1), node(0x01, 2), node(0x10, 3)], 3);
|
||||
let closest = snapshot.closest_nodes(&[0; 20], 2, Some(false));
|
||||
assert_eq!(
|
||||
closest
|
||||
.iter()
|
||||
.map(|node| node.addr.port())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2, 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,36 @@ pub struct DhtRuntimeSnapshot {
|
||||
pub metadata_queue_evicted: u64,
|
||||
/// Events rejected or expired because they were stale.
|
||||
pub metadata_queue_stale: u64,
|
||||
/// Metadata Peer races started after admission.
|
||||
pub metadata_races_started: u64,
|
||||
/// Distinct Peer candidates launched into Metadata races.
|
||||
pub metadata_peer_candidates: u64,
|
||||
/// Peer candidates added while a Metadata race was already running.
|
||||
pub metadata_live_peer_joins: u64,
|
||||
/// Losing Peer attempts canceled after another Peer succeeded.
|
||||
pub metadata_peer_canceled: u64,
|
||||
/// Active `get_peers` lookup requests received from Metadata jobs.
|
||||
pub peer_lookup_requested: u64,
|
||||
/// Active `get_peers` lookups admitted by the bounded rate limiter.
|
||||
pub peer_lookup_started: u64,
|
||||
/// Active lookup requests rejected by concurrency or rate limits.
|
||||
pub peer_lookup_rate_limited: u64,
|
||||
/// Active lookup requests skipped because the routing snapshot was empty.
|
||||
pub peer_lookup_empty: u64,
|
||||
/// Active `get_peers` UDP queries sent.
|
||||
pub peer_lookup_queries: u64,
|
||||
/// Matched active `get_peers` responses.
|
||||
pub peer_lookup_responses: u64,
|
||||
/// Active `get_peers` queries that timed out.
|
||||
pub peer_lookup_timeouts: u64,
|
||||
/// Active `get_peers` UDP sends that failed immediately.
|
||||
pub peer_lookup_send_failures: u64,
|
||||
/// Unique Peer endpoints fed back into Metadata scheduling.
|
||||
pub peer_lookup_peers_found: u64,
|
||||
/// Tagged lookup responses dropped before reaching the lookup actor.
|
||||
pub peer_lookup_response_dropped: u64,
|
||||
/// Discovered Peer endpoints dropped because Hash ingress was full.
|
||||
pub peer_lookup_output_dropped: u64,
|
||||
/// Real Peer network attempts.
|
||||
pub metadata_peer_attempts: u64,
|
||||
/// Successful Peer downloads and parses.
|
||||
@@ -295,6 +325,21 @@ struct DhtRuntimeStatsInner {
|
||||
metadata_queue_deduplicated: AtomicU64,
|
||||
metadata_queue_evicted: AtomicU64,
|
||||
metadata_queue_stale: AtomicU64,
|
||||
metadata_races_started: AtomicU64,
|
||||
metadata_peer_candidates: AtomicU64,
|
||||
metadata_live_peer_joins: AtomicU64,
|
||||
metadata_peer_canceled: AtomicU64,
|
||||
peer_lookup_requested: AtomicU64,
|
||||
peer_lookup_started: AtomicU64,
|
||||
peer_lookup_rate_limited: AtomicU64,
|
||||
peer_lookup_empty: AtomicU64,
|
||||
peer_lookup_queries: AtomicU64,
|
||||
peer_lookup_responses: AtomicU64,
|
||||
peer_lookup_timeouts: AtomicU64,
|
||||
peer_lookup_send_failures: AtomicU64,
|
||||
peer_lookup_peers_found: AtomicU64,
|
||||
peer_lookup_response_dropped: AtomicU64,
|
||||
peer_lookup_output_dropped: AtomicU64,
|
||||
metadata_peer_attempts: AtomicU64,
|
||||
metadata_peer_succeeded: AtomicU64,
|
||||
metadata_peer_failed: AtomicU64,
|
||||
@@ -382,6 +427,21 @@ impl Default for DhtRuntimeStatsInner {
|
||||
metadata_queue_deduplicated: AtomicU64::new(0),
|
||||
metadata_queue_evicted: AtomicU64::new(0),
|
||||
metadata_queue_stale: AtomicU64::new(0),
|
||||
metadata_races_started: AtomicU64::new(0),
|
||||
metadata_peer_candidates: AtomicU64::new(0),
|
||||
metadata_live_peer_joins: AtomicU64::new(0),
|
||||
metadata_peer_canceled: AtomicU64::new(0),
|
||||
peer_lookup_requested: AtomicU64::new(0),
|
||||
peer_lookup_started: AtomicU64::new(0),
|
||||
peer_lookup_rate_limited: AtomicU64::new(0),
|
||||
peer_lookup_empty: AtomicU64::new(0),
|
||||
peer_lookup_queries: AtomicU64::new(0),
|
||||
peer_lookup_responses: AtomicU64::new(0),
|
||||
peer_lookup_timeouts: AtomicU64::new(0),
|
||||
peer_lookup_send_failures: AtomicU64::new(0),
|
||||
peer_lookup_peers_found: AtomicU64::new(0),
|
||||
peer_lookup_response_dropped: AtomicU64::new(0),
|
||||
peer_lookup_output_dropped: AtomicU64::new(0),
|
||||
metadata_peer_attempts: AtomicU64::new(0),
|
||||
metadata_peer_succeeded: AtomicU64::new(0),
|
||||
metadata_peer_failed: AtomicU64::new(0),
|
||||
@@ -492,6 +552,23 @@ impl DhtRuntimeStats {
|
||||
metadata_queue_deduplicated: inner.metadata_queue_deduplicated.load(Ordering::Relaxed),
|
||||
metadata_queue_evicted: inner.metadata_queue_evicted.load(Ordering::Relaxed),
|
||||
metadata_queue_stale: inner.metadata_queue_stale.load(Ordering::Relaxed),
|
||||
metadata_races_started: inner.metadata_races_started.load(Ordering::Relaxed),
|
||||
metadata_peer_candidates: inner.metadata_peer_candidates.load(Ordering::Relaxed),
|
||||
metadata_live_peer_joins: inner.metadata_live_peer_joins.load(Ordering::Relaxed),
|
||||
metadata_peer_canceled: inner.metadata_peer_canceled.load(Ordering::Relaxed),
|
||||
peer_lookup_requested: inner.peer_lookup_requested.load(Ordering::Relaxed),
|
||||
peer_lookup_started: inner.peer_lookup_started.load(Ordering::Relaxed),
|
||||
peer_lookup_rate_limited: inner.peer_lookup_rate_limited.load(Ordering::Relaxed),
|
||||
peer_lookup_empty: inner.peer_lookup_empty.load(Ordering::Relaxed),
|
||||
peer_lookup_queries: inner.peer_lookup_queries.load(Ordering::Relaxed),
|
||||
peer_lookup_responses: inner.peer_lookup_responses.load(Ordering::Relaxed),
|
||||
peer_lookup_timeouts: inner.peer_lookup_timeouts.load(Ordering::Relaxed),
|
||||
peer_lookup_send_failures: inner.peer_lookup_send_failures.load(Ordering::Relaxed),
|
||||
peer_lookup_peers_found: inner.peer_lookup_peers_found.load(Ordering::Relaxed),
|
||||
peer_lookup_response_dropped: inner
|
||||
.peer_lookup_response_dropped
|
||||
.load(Ordering::Relaxed),
|
||||
peer_lookup_output_dropped: inner.peer_lookup_output_dropped.load(Ordering::Relaxed),
|
||||
metadata_peer_attempts: inner.metadata_peer_attempts.load(Ordering::Relaxed),
|
||||
metadata_peer_succeeded: inner.metadata_peer_succeeded.load(Ordering::Relaxed),
|
||||
metadata_peer_failed: inner.metadata_peer_failed.load(Ordering::Relaxed),
|
||||
@@ -642,6 +719,94 @@ impl DhtRuntimeStats {
|
||||
.fetch_add(count as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_race_started(&self) {
|
||||
self.inner
|
||||
.metadata_races_started
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_peer_candidate(&self) {
|
||||
self.inner
|
||||
.metadata_peer_candidates
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_live_peer_join(&self) {
|
||||
self.inner
|
||||
.metadata_live_peer_joins
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_peer_canceled(&self, count: usize) {
|
||||
self.inner
|
||||
.metadata_peer_canceled
|
||||
.fetch_add(count as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_requested(&self) {
|
||||
self.inner
|
||||
.peer_lookup_requested
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_started(&self) {
|
||||
self.inner
|
||||
.peer_lookup_started
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_rate_limited(&self) {
|
||||
self.inner
|
||||
.peer_lookup_rate_limited
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_empty(&self) {
|
||||
self.inner.peer_lookup_empty.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_query(&self) {
|
||||
self.inner
|
||||
.peer_lookup_queries
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_response(&self) {
|
||||
self.inner
|
||||
.peer_lookup_responses
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_timeout(&self) {
|
||||
self.inner
|
||||
.peer_lookup_timeouts
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_send_failed(&self) {
|
||||
self.inner
|
||||
.peer_lookup_send_failures
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_peer_found(&self) {
|
||||
self.inner
|
||||
.peer_lookup_peers_found
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_response_dropped(&self) {
|
||||
self.inner
|
||||
.peer_lookup_response_dropped
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn peer_lookup_output_dropped(&self) {
|
||||
self.inner
|
||||
.peer_lookup_output_dropped
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_peer_attempt(&self) {
|
||||
self.inner
|
||||
.metadata_peer_attempts
|
||||
@@ -953,6 +1118,21 @@ mod tests {
|
||||
writer.metadata_queue_deduplicated();
|
||||
writer.metadata_queue_evicted();
|
||||
writer.metadata_queue_stale(3);
|
||||
writer.metadata_race_started();
|
||||
writer.metadata_peer_candidate();
|
||||
writer.metadata_live_peer_join();
|
||||
writer.metadata_peer_canceled(2);
|
||||
writer.peer_lookup_requested();
|
||||
writer.peer_lookup_started();
|
||||
writer.peer_lookup_rate_limited();
|
||||
writer.peer_lookup_empty();
|
||||
writer.peer_lookup_query();
|
||||
writer.peer_lookup_response();
|
||||
writer.peer_lookup_timeout();
|
||||
writer.peer_lookup_send_failed();
|
||||
writer.peer_lookup_peer_found();
|
||||
writer.peer_lookup_response_dropped();
|
||||
writer.peer_lookup_output_dropped();
|
||||
writer.metadata_peer_attempt();
|
||||
writer.metadata_peer_succeeded();
|
||||
writer.metadata_peer_failed();
|
||||
@@ -997,6 +1177,21 @@ mod tests {
|
||||
metadata_queue_deduplicated: 1,
|
||||
metadata_queue_evicted: 1,
|
||||
metadata_queue_stale: 3,
|
||||
metadata_races_started: 1,
|
||||
metadata_peer_candidates: 1,
|
||||
metadata_live_peer_joins: 1,
|
||||
metadata_peer_canceled: 2,
|
||||
peer_lookup_requested: 1,
|
||||
peer_lookup_started: 1,
|
||||
peer_lookup_rate_limited: 1,
|
||||
peer_lookup_empty: 1,
|
||||
peer_lookup_queries: 1,
|
||||
peer_lookup_responses: 1,
|
||||
peer_lookup_timeouts: 1,
|
||||
peer_lookup_send_failures: 1,
|
||||
peer_lookup_peers_found: 1,
|
||||
peer_lookup_response_dropped: 1,
|
||||
peer_lookup_output_dropped: 1,
|
||||
metadata_peer_attempts: 1,
|
||||
metadata_peer_succeeded: 1,
|
||||
metadata_peer_failed: 1,
|
||||
|
||||
+492
-106
@@ -1,4 +1,4 @@
|
||||
use crate::metadata::{MetadataFetchOutcome, RbitFetcher};
|
||||
use crate::metadata::{FetchedMetadata, MetadataFetchOutcome, RbitFetcher};
|
||||
#[cfg(test)]
|
||||
use crate::runtime_stats::DhtRuntimeLimits;
|
||||
use crate::runtime_stats::DhtRuntimeStats;
|
||||
@@ -8,6 +8,7 @@ use arc_swap::ArcSwapOption;
|
||||
#[cfg(feature = "metrics")]
|
||||
use metrics::{counter, gauge, histogram};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
@@ -17,8 +18,13 @@ use tokio::sync::mpsc;
|
||||
use tokio::task::JoinSet;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_PEERS_PER_HASH: usize = 3;
|
||||
/// Maximum distinct Peer endpoints retained and raced for one InfoHash.
|
||||
pub const MAX_METADATA_PEERS_PER_HASH: usize = 12;
|
||||
const HASH_QUEUE_TTL: Duration = Duration::from_secs(60);
|
||||
const PEER_COALESCE_WINDOW: Duration = Duration::from_millis(25);
|
||||
const LIVE_PEER_IDLE_GRACE: Duration = Duration::from_millis(300);
|
||||
const ACTIVE_PEER_LOOKUP_DELAY: Duration = Duration::from_millis(100);
|
||||
const DISPATCH_TICK: Duration = Duration::from_millis(25);
|
||||
|
||||
/// Callback returning whether the application accepted a downloaded torrent.
|
||||
pub type TorrentAckCallback = Box<dyn Fn(TorrentInfo) -> bool + Send + Sync + 'static>;
|
||||
@@ -52,6 +58,11 @@ pub struct MetadataSchedulerCallbacks {
|
||||
pub completion: Arc<ArcSwapOption<MetadataCompletionCallback>>,
|
||||
}
|
||||
|
||||
pub(crate) struct MetadataSchedulerRuntime {
|
||||
pub(crate) stats: DhtRuntimeStats,
|
||||
pub(crate) peer_lookup_tx: Option<mpsc::Sender<[u8; 20]>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct PeerCandidate {
|
||||
addr: SocketAddr,
|
||||
@@ -62,6 +73,8 @@ struct PeerCandidate {
|
||||
struct QueuedHash {
|
||||
info_hash: String,
|
||||
peers: Vec<PeerCandidate>,
|
||||
queued_at: Instant,
|
||||
ready_at: Instant,
|
||||
latest_at: Instant,
|
||||
order_key: (Instant, u64),
|
||||
}
|
||||
@@ -137,7 +150,7 @@ impl PendingHashQueue {
|
||||
entry
|
||||
.peers
|
||||
.sort_unstable_by(|left, right| right.discovered_at.cmp(&left.discovered_at));
|
||||
entry.peers.truncate(MAX_PEERS_PER_HASH);
|
||||
entry.peers.truncate(MAX_METADATA_PEERS_PER_HASH);
|
||||
entry.latest_at = entry.latest_at.max(event.discovered_at);
|
||||
entry.order_key = self.next_order_key(entry.latest_at);
|
||||
self.order.insert(entry.order_key, entry.info_hash.clone());
|
||||
@@ -170,6 +183,8 @@ impl PendingHashQueue {
|
||||
addr: event.peer_addr,
|
||||
discovered_at: event.discovered_at,
|
||||
}],
|
||||
queued_at: now,
|
||||
ready_at: now + PEER_COALESCE_WINDOW,
|
||||
latest_at: event.discovered_at,
|
||||
order_key,
|
||||
},
|
||||
@@ -183,15 +198,15 @@ impl PendingHashQueue {
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
fn pop_newest_available(
|
||||
fn pop_newest_ready(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
in_flight: &HashMap<String, InFlightState>,
|
||||
) -> Option<QueuedHash> {
|
||||
let info_hash = self
|
||||
.order
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(_, hash)| (!in_flight.contains_key(hash)).then(|| hash.clone()))?;
|
||||
let info_hash = self.order.iter().rev().find_map(|(_, hash)| {
|
||||
let entry = self.entries.get(hash)?;
|
||||
(!in_flight.contains_key(hash) && entry.ready_at <= now).then(|| hash.clone())
|
||||
})?;
|
||||
self.remove(&info_hash)
|
||||
}
|
||||
|
||||
@@ -217,12 +232,12 @@ impl PendingHashQueue {
|
||||
struct MetadataJob {
|
||||
info_hash: String,
|
||||
peers: Vec<PeerCandidate>,
|
||||
already_admitted: bool,
|
||||
peer_rx: mpsc::Receiver<PeerCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InFlightState {
|
||||
attempts: usize,
|
||||
peer_tx: mpsc::Sender<PeerCandidate>,
|
||||
scheduled_peers: HashSet<SocketAddr>,
|
||||
}
|
||||
|
||||
@@ -254,6 +269,7 @@ pub struct MetadataScheduler {
|
||||
total_completed: Arc<AtomicU64>,
|
||||
queue_len: Arc<AtomicUsize>,
|
||||
runtime_stats: DhtRuntimeStats,
|
||||
peer_lookup_tx: Option<mpsc::Sender<[u8; 20]>>,
|
||||
shutdown: CancellationToken,
|
||||
}
|
||||
|
||||
@@ -286,6 +302,29 @@ impl MetadataScheduler {
|
||||
queue_len: Arc<AtomicUsize>,
|
||||
shutdown: CancellationToken,
|
||||
runtime_stats: DhtRuntimeStats,
|
||||
) -> Self {
|
||||
Self::new_with_runtime(
|
||||
hash_rx,
|
||||
fetcher,
|
||||
limits,
|
||||
callbacks,
|
||||
queue_len,
|
||||
shutdown,
|
||||
MetadataSchedulerRuntime {
|
||||
stats: runtime_stats,
|
||||
peer_lookup_tx: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_with_runtime(
|
||||
hash_rx: mpsc::Receiver<HashDiscovered>,
|
||||
fetcher: Arc<RbitFetcher>,
|
||||
limits: MetadataSchedulerLimits,
|
||||
callbacks: MetadataSchedulerCallbacks,
|
||||
queue_len: Arc<AtomicUsize>,
|
||||
shutdown: CancellationToken,
|
||||
runtime: MetadataSchedulerRuntime,
|
||||
) -> Self {
|
||||
Self {
|
||||
hash_rx,
|
||||
@@ -300,7 +339,8 @@ impl MetadataScheduler {
|
||||
total_dispatched: Arc::new(AtomicU64::new(0)),
|
||||
total_completed: Arc::new(AtomicU64::new(0)),
|
||||
queue_len,
|
||||
runtime_stats,
|
||||
runtime_stats: runtime.stats,
|
||||
peer_lookup_tx: runtime.peer_lookup_tx,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
@@ -326,7 +366,11 @@ impl MetadataScheduler {
|
||||
let mut in_flight = HashMap::<String, InFlightState>::new();
|
||||
let mut tasks = JoinSet::<JobResult>::new();
|
||||
let mut maintenance = tokio::time::interval(Duration::from_secs(1));
|
||||
maintenance.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut dispatch_tick = tokio::time::interval(DISPATCH_TICK);
|
||||
dispatch_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut stats_interval = tokio::time::interval(Duration::from_secs(60));
|
||||
stats_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut input_closed = false;
|
||||
|
||||
loop {
|
||||
@@ -343,14 +387,14 @@ impl MetadataScheduler {
|
||||
self.runtime_stats
|
||||
.set_hash_ingress_queue_depth(self.hash_rx.len());
|
||||
match result {
|
||||
Some(hash) => self.enqueue(&mut queue, hash),
|
||||
Some(hash) => self.enqueue(&mut queue, &mut in_flight, hash),
|
||||
None => input_closed = true,
|
||||
}
|
||||
}
|
||||
result = tasks.join_next(), if !tasks.is_empty() => {
|
||||
match result {
|
||||
Some(Ok(result)) => {
|
||||
self.handle_job_result(result, &mut queue, &mut in_flight, &mut tasks);
|
||||
self.handle_job_result(result, &mut queue, &mut in_flight);
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
#[cfg(feature = "metrics")]
|
||||
@@ -360,6 +404,7 @@ impl MetadataScheduler {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
_ = dispatch_tick.tick() => {}
|
||||
_ = maintenance.tick() => {
|
||||
let expired = queue.expire(Instant::now());
|
||||
if expired > 0 {
|
||||
@@ -383,9 +428,50 @@ impl MetadataScheduler {
|
||||
gauge!("dht_metadata_queue_depth").set(0.0);
|
||||
}
|
||||
|
||||
fn enqueue(&self, queue: &mut PendingHashQueue, hash: HashDiscovered) {
|
||||
fn enqueue(
|
||||
&self,
|
||||
queue: &mut PendingHashQueue,
|
||||
in_flight: &mut HashMap<String, InFlightState>,
|
||||
hash: HashDiscovered,
|
||||
) {
|
||||
self.total_received.fetch_add(1, Ordering::Relaxed);
|
||||
let kind = queue.push(hash, Instant::now());
|
||||
let now = Instant::now();
|
||||
if let Some(state) = in_flight.get_mut(&hash.info_hash) {
|
||||
let kind = if now
|
||||
.checked_duration_since(hash.discovered_at)
|
||||
.unwrap_or_default()
|
||||
> HASH_QUEUE_TTL
|
||||
{
|
||||
QueuePushKind::Stale
|
||||
} else if state.scheduled_peers.contains(&hash.peer_addr)
|
||||
|| state.scheduled_peers.len() >= MAX_METADATA_PEERS_PER_HASH
|
||||
{
|
||||
QueuePushKind::Updated
|
||||
} else {
|
||||
let peer = PeerCandidate {
|
||||
addr: hash.peer_addr,
|
||||
discovered_at: hash.discovered_at,
|
||||
};
|
||||
match state.peer_tx.try_send(peer) {
|
||||
Ok(()) => {
|
||||
state.scheduled_peers.insert(hash.peer_addr);
|
||||
self.runtime_stats.metadata_live_peer_join();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_live_peer_joins_total").increment(1);
|
||||
QueuePushKind::Updated
|
||||
}
|
||||
Err(_) => QueuePushKind::Updated,
|
||||
}
|
||||
};
|
||||
self.record_queue_push(kind);
|
||||
return;
|
||||
}
|
||||
|
||||
let kind = queue.push(hash, now);
|
||||
self.record_queue_push(kind);
|
||||
}
|
||||
|
||||
fn record_queue_push(&self, kind: QueuePushKind) {
|
||||
let result = match kind {
|
||||
QueuePushKind::Inserted => {
|
||||
self.runtime_stats.metadata_queue_inserted();
|
||||
@@ -420,68 +506,45 @@ impl MetadataScheduler {
|
||||
tasks: &mut JoinSet<JobResult>,
|
||||
) {
|
||||
while tasks.len() < self.max_concurrent {
|
||||
let Some(entry) = queue.pop_newest_available(in_flight) else {
|
||||
let Some(mut entry) = queue.pop_newest_ready(Instant::now(), in_flight) else {
|
||||
break;
|
||||
};
|
||||
let queue_wait = Instant::now()
|
||||
.checked_duration_since(entry.latest_at)
|
||||
.checked_duration_since(entry.queued_at)
|
||||
.unwrap_or_default();
|
||||
self.runtime_stats.observe_metadata_queue_wait(
|
||||
queue_wait.as_millis().min(u128::from(u64::MAX)) as u64,
|
||||
);
|
||||
#[cfg(feature = "metrics")]
|
||||
histogram!("dht_metadata_queue_wait_seconds").record(queue_wait.as_secs_f64());
|
||||
let now = Instant::now();
|
||||
entry.peers.retain(|peer| {
|
||||
now.checked_duration_since(peer.discovered_at)
|
||||
.unwrap_or_default()
|
||||
<= HASH_QUEUE_TTL
|
||||
});
|
||||
entry.peers.truncate(MAX_METADATA_PEERS_PER_HASH);
|
||||
if entry.peers.is_empty() {
|
||||
self.total_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.runtime_stats.metadata_queue_stale(1);
|
||||
continue;
|
||||
}
|
||||
let (peer_tx, peer_rx) = mpsc::channel(MAX_METADATA_PEERS_PER_HASH);
|
||||
let state = InFlightState {
|
||||
attempts: 0,
|
||||
scheduled_peers: HashSet::new(),
|
||||
peer_tx,
|
||||
scheduled_peers: entry.peers.iter().map(|peer| peer.addr).collect(),
|
||||
};
|
||||
in_flight.insert(entry.info_hash.clone(), state);
|
||||
self.spawn_job(entry, false, in_flight, tasks);
|
||||
self.spawn_job(entry, peer_rx, tasks);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_job(
|
||||
&self,
|
||||
mut entry: QueuedHash,
|
||||
already_admitted: bool,
|
||||
in_flight: &mut HashMap<String, InFlightState>,
|
||||
entry: QueuedHash,
|
||||
peer_rx: mpsc::Receiver<PeerCandidate>,
|
||||
tasks: &mut JoinSet<JobResult>,
|
||||
) {
|
||||
let Some(state) = in_flight.get_mut(&entry.info_hash) else {
|
||||
return;
|
||||
};
|
||||
let remaining = MAX_PEERS_PER_HASH.saturating_sub(state.attempts);
|
||||
let now = Instant::now();
|
||||
entry.peers.retain(|peer| {
|
||||
!state.scheduled_peers.contains(&peer.addr)
|
||||
&& now
|
||||
.checked_duration_since(peer.discovered_at)
|
||||
.unwrap_or_default()
|
||||
<= HASH_QUEUE_TTL
|
||||
});
|
||||
entry.peers.truncate(remaining);
|
||||
for peer in &entry.peers {
|
||||
state.scheduled_peers.insert(peer.addr);
|
||||
}
|
||||
|
||||
if entry.peers.is_empty() {
|
||||
let attempts = state.attempts;
|
||||
if already_admitted {
|
||||
self.finish(
|
||||
entry.info_hash.clone(),
|
||||
MetadataFetchCompletionStatus::FetchFailed,
|
||||
attempts,
|
||||
);
|
||||
} else {
|
||||
self.total_dropped.fetch_add(1, Ordering::Relaxed);
|
||||
self.runtime_stats.metadata_queue_stale(1);
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_queue_events_total", "result" => "expired").increment(1);
|
||||
}
|
||||
in_flight.remove(&entry.info_hash);
|
||||
return;
|
||||
}
|
||||
|
||||
self.total_dispatched.fetch_add(1, Ordering::Relaxed);
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_jobs_dispatched_total").increment(1);
|
||||
@@ -489,13 +552,23 @@ impl MetadataScheduler {
|
||||
let job = MetadataJob {
|
||||
info_hash: entry.info_hash,
|
||||
peers: entry.peers,
|
||||
already_admitted,
|
||||
peer_rx,
|
||||
};
|
||||
let fetcher = self.fetcher.clone();
|
||||
let callback = self.callback.clone();
|
||||
let on_metadata_fetch = self.on_metadata_fetch.clone();
|
||||
let runtime_stats = self.runtime_stats.clone();
|
||||
let peer_lookup_tx = self.peer_lookup_tx.clone();
|
||||
tasks.spawn(async move {
|
||||
Self::process_hash(job, &fetcher, &callback, &on_metadata_fetch).await
|
||||
Self::process_hash(
|
||||
job,
|
||||
&fetcher,
|
||||
&callback,
|
||||
&on_metadata_fetch,
|
||||
&runtime_stats,
|
||||
peer_lookup_tx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
|
||||
@@ -504,12 +577,10 @@ impl MetadataScheduler {
|
||||
result: JobResult,
|
||||
queue: &mut PendingHashQueue,
|
||||
in_flight: &mut HashMap<String, InFlightState>,
|
||||
tasks: &mut JoinSet<JobResult>,
|
||||
) {
|
||||
let Some(state) = in_flight.get_mut(&result.info_hash) else {
|
||||
if !in_flight.contains_key(&result.info_hash) {
|
||||
return;
|
||||
};
|
||||
state.attempts = state.attempts.saturating_add(result.attempts);
|
||||
}
|
||||
|
||||
match result.outcome {
|
||||
JobOutcome::GateRejected => {
|
||||
@@ -519,26 +590,10 @@ impl MetadataScheduler {
|
||||
counter!("dht_metadata_jobs_completed_total", "result" => "gate_rejected")
|
||||
.increment(1);
|
||||
}
|
||||
JobOutcome::Completed(MetadataFetchCompletionStatus::FetchFailed)
|
||||
if state.attempts < MAX_PEERS_PER_HASH =>
|
||||
{
|
||||
if let Some(entry) = queue.remove(&result.info_hash) {
|
||||
self.spawn_job(entry, true, in_flight, tasks);
|
||||
} else {
|
||||
let attempts = state.attempts;
|
||||
in_flight.remove(&result.info_hash);
|
||||
self.finish(
|
||||
result.info_hash,
|
||||
MetadataFetchCompletionStatus::FetchFailed,
|
||||
attempts,
|
||||
);
|
||||
}
|
||||
}
|
||||
JobOutcome::Completed(status) => {
|
||||
let attempts = state.attempts;
|
||||
queue.remove(&result.info_hash);
|
||||
in_flight.remove(&result.info_hash);
|
||||
self.finish(result.info_hash, status, attempts);
|
||||
self.finish(result.info_hash, status, result.attempts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,13 +625,14 @@ impl MetadataScheduler {
|
||||
}
|
||||
|
||||
async fn process_hash(
|
||||
job: MetadataJob,
|
||||
mut job: MetadataJob,
|
||||
fetcher: &Arc<RbitFetcher>,
|
||||
callback: &Arc<ArcSwapOption<TorrentAckCallback>>,
|
||||
on_metadata_fetch: &Arc<ArcSwapOption<MetadataFetchCallback>>,
|
||||
runtime_stats: &DhtRuntimeStats,
|
||||
peer_lookup_tx: Option<mpsc::Sender<[u8; 20]>>,
|
||||
) -> JobResult {
|
||||
if !job.already_admitted
|
||||
&& let Some(gate) = on_metadata_fetch.load_full()
|
||||
if let Some(gate) = on_metadata_fetch.load_full()
|
||||
&& !gate(job.info_hash.clone()).await
|
||||
{
|
||||
return JobResult {
|
||||
@@ -601,21 +657,39 @@ impl MetadataScheduler {
|
||||
}
|
||||
};
|
||||
|
||||
let mut attempts = 0;
|
||||
for peer in job.peers {
|
||||
let (name, total_size, files, piece_length) =
|
||||
match fetcher.fetch(&info_hash_bytes, peer.addr).await {
|
||||
MetadataFetchOutcome::Fetched(metadata) => {
|
||||
attempts += 1;
|
||||
metadata
|
||||
}
|
||||
MetadataFetchOutcome::Failed => {
|
||||
attempts += 1;
|
||||
continue;
|
||||
}
|
||||
MetadataFetchOutcome::SkippedCached => continue,
|
||||
};
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempts_for_fetch = attempts.clone();
|
||||
let fetcher_for_race = fetcher.clone();
|
||||
let lookup_task = peer_lookup_tx.map(|peer_lookup_tx| {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(ACTIVE_PEER_LOOKUP_DELAY).await;
|
||||
let _ = peer_lookup_tx.try_send(info_hash_bytes);
|
||||
})
|
||||
});
|
||||
let fetched = race_peer_fetches(
|
||||
job.peers,
|
||||
&mut job.peer_rx,
|
||||
move |peer| {
|
||||
let attempts = attempts_for_fetch.clone();
|
||||
let fetcher = fetcher_for_race.clone();
|
||||
async move {
|
||||
let outcome = fetcher
|
||||
.fetch_with_attempt_observer(&info_hash_bytes, peer.addr, move || {
|
||||
attempts.fetch_add(1, Ordering::Relaxed);
|
||||
})
|
||||
.await;
|
||||
(peer, outcome)
|
||||
}
|
||||
},
|
||||
runtime_stats,
|
||||
)
|
||||
.await;
|
||||
if let Some(task) = lookup_task {
|
||||
task.abort();
|
||||
}
|
||||
let attempts = attempts.load(Ordering::Relaxed);
|
||||
|
||||
if let Some((peer, (name, total_size, files, piece_length))) = fetched {
|
||||
let metadata = TorrentInfo {
|
||||
info_hash: job.info_hash.clone(),
|
||||
name,
|
||||
@@ -686,9 +760,99 @@ impl MetadataScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn race_peer_fetches<F, Fut>(
|
||||
peers: Vec<PeerCandidate>,
|
||||
peer_rx: &mut mpsc::Receiver<PeerCandidate>,
|
||||
fetch: F,
|
||||
runtime_stats: &DhtRuntimeStats,
|
||||
) -> Option<(PeerCandidate, FetchedMetadata)>
|
||||
where
|
||||
F: Fn(PeerCandidate) -> Fut + Clone + Send + Sync + 'static,
|
||||
Fut: Future<Output = (PeerCandidate, MetadataFetchOutcome)> + Send + 'static,
|
||||
{
|
||||
runtime_stats.metadata_race_started();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_peer_races_total").increment(1);
|
||||
|
||||
let mut tasks = JoinSet::new();
|
||||
let mut candidates = 0usize;
|
||||
for peer in peers {
|
||||
let fetch = fetch.clone();
|
||||
tasks.spawn(fetch(peer));
|
||||
candidates += 1;
|
||||
runtime_stats.metadata_peer_candidate();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_peer_candidates_total", "source" => "initial").increment(1);
|
||||
}
|
||||
|
||||
let mut accepting_live_peers = candidates < MAX_METADATA_PEERS_PER_HASH;
|
||||
loop {
|
||||
if tasks.is_empty() {
|
||||
if !accepting_live_peers {
|
||||
return None;
|
||||
}
|
||||
match tokio::time::timeout(LIVE_PEER_IDLE_GRACE, peer_rx.recv()).await {
|
||||
Ok(Some(peer)) => {
|
||||
let fetch = fetch.clone();
|
||||
tasks.spawn(fetch(peer));
|
||||
candidates += 1;
|
||||
runtime_stats.metadata_peer_candidate();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_peer_candidates_total", "source" => "live").increment(1);
|
||||
accepting_live_peers = candidates < MAX_METADATA_PEERS_PER_HASH;
|
||||
}
|
||||
Ok(None) | Err(_) => return None,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
result = tasks.join_next() => {
|
||||
let Some(Ok((peer, outcome))) = result else {
|
||||
continue;
|
||||
};
|
||||
if let MetadataFetchOutcome::Fetched(metadata) = outcome {
|
||||
let canceled = tasks.len();
|
||||
runtime_stats.metadata_peer_canceled(canceled);
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_peer_canceled_total").increment(canceled as u64);
|
||||
peer_rx.close();
|
||||
tasks.abort_all();
|
||||
while tasks.join_next().await.is_some() {}
|
||||
return Some((peer, metadata));
|
||||
}
|
||||
}
|
||||
peer = peer_rx.recv(), if accepting_live_peers => {
|
||||
match peer {
|
||||
Some(peer) => {
|
||||
let fetch = fetch.clone();
|
||||
tasks.spawn(fetch(peer));
|
||||
candidates += 1;
|
||||
runtime_stats.metadata_peer_candidate();
|
||||
#[cfg(feature = "metrics")]
|
||||
counter!("dht_metadata_peer_candidates_total", "source" => "live")
|
||||
.increment(1);
|
||||
accepting_live_peers = candidates < MAX_METADATA_PEERS_PER_HASH;
|
||||
}
|
||||
None => accepting_live_peers = false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
struct DropSignal(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for DropSignal {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_torrent_callback() -> Arc<ArcSwapOption<TorrentAckCallback>> {
|
||||
Arc::new(ArcSwapOption::empty())
|
||||
@@ -749,22 +913,27 @@ mod tests {
|
||||
);
|
||||
let start = Instant::now();
|
||||
let mut queue = PendingHashQueue::new(2, HASH_QUEUE_TTL);
|
||||
let mut in_flight = HashMap::new();
|
||||
|
||||
scheduler.enqueue(&mut queue, event("old", 1000, start));
|
||||
scheduler.enqueue(&mut queue, &mut in_flight, event("old", 1000, start));
|
||||
scheduler.enqueue(
|
||||
&mut queue,
|
||||
&mut in_flight,
|
||||
event("old", 1001, start + Duration::from_secs(1)),
|
||||
);
|
||||
scheduler.enqueue(
|
||||
&mut queue,
|
||||
&mut in_flight,
|
||||
event("middle", 1002, start + Duration::from_secs(2)),
|
||||
);
|
||||
scheduler.enqueue(
|
||||
&mut queue,
|
||||
&mut in_flight,
|
||||
event("new", 1003, start + Duration::from_secs(3)),
|
||||
);
|
||||
scheduler.enqueue(
|
||||
&mut queue,
|
||||
&mut in_flight,
|
||||
event(
|
||||
"stale",
|
||||
1004,
|
||||
@@ -786,10 +955,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_deduplicates_hash_and_keeps_newest_unique_peers() {
|
||||
fn queue_deduplicates_hash_and_keeps_twelve_newest_unique_peers() {
|
||||
let start = Instant::now();
|
||||
let mut queue = PendingHashQueue::new(10, Duration::from_secs(60));
|
||||
for offset in 0..4 {
|
||||
for offset in 0..13 {
|
||||
assert_ne!(
|
||||
queue.push(
|
||||
event(
|
||||
@@ -803,14 +972,224 @@ mod tests {
|
||||
);
|
||||
}
|
||||
queue.push(
|
||||
event("hash", 1003, start + Duration::from_secs(5)),
|
||||
start + Duration::from_secs(5),
|
||||
event("hash", 1012, start + Duration::from_secs(20)),
|
||||
start + Duration::from_secs(20),
|
||||
);
|
||||
|
||||
assert_eq!(queue.len(), 1);
|
||||
let entry = queue.remove("hash").unwrap();
|
||||
let ports: Vec<_> = entry.peers.iter().map(|peer| peer.addr.port()).collect();
|
||||
assert_eq!(ports, vec![1003, 1002, 1001]);
|
||||
assert_eq!(ports, (1001..=1012).rev().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalescing_window_is_fixed_from_first_enqueue() {
|
||||
let start = Instant::now();
|
||||
let mut queue = PendingHashQueue::new(10, Duration::from_secs(60));
|
||||
let in_flight = HashMap::new();
|
||||
queue.push(event("hash", 1000, start), start);
|
||||
queue.push(
|
||||
event("hash", 1001, start + Duration::from_millis(15)),
|
||||
start + Duration::from_millis(15),
|
||||
);
|
||||
|
||||
assert!(
|
||||
queue
|
||||
.pop_newest_ready(start + Duration::from_millis(24), &in_flight)
|
||||
.is_none()
|
||||
);
|
||||
let entry = queue
|
||||
.pop_newest_ready(start + Duration::from_millis(25), &in_flight)
|
||||
.expect("duplicate arrivals must not extend the coalescing deadline");
|
||||
assert_eq!(entry.peers.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_flight_hash_routes_at_most_twelve_unique_peers_to_live_race() {
|
||||
let (_hash_tx, hash_rx) = mpsc::channel(4);
|
||||
let stats = DhtRuntimeStats::default();
|
||||
let scheduler = MetadataScheduler::new_with_runtime_stats(
|
||||
hash_rx,
|
||||
Arc::new(RbitFetcher::new(1)),
|
||||
MetadataSchedulerLimits {
|
||||
queue_size: 4,
|
||||
concurrency: 1,
|
||||
},
|
||||
MetadataSchedulerCallbacks {
|
||||
torrent: empty_torrent_callback(),
|
||||
fetch_gate: Arc::new(ArcSwapOption::empty()),
|
||||
completion: Arc::new(ArcSwapOption::empty()),
|
||||
},
|
||||
Arc::new(AtomicUsize::new(0)),
|
||||
CancellationToken::new(),
|
||||
stats.clone(),
|
||||
);
|
||||
let (peer_tx, mut peer_rx) = mpsc::channel(MAX_METADATA_PEERS_PER_HASH);
|
||||
let mut in_flight = HashMap::from([(
|
||||
"hash".to_string(),
|
||||
InFlightState {
|
||||
peer_tx,
|
||||
scheduled_peers: HashSet::from([SocketAddr::from(([127, 0, 0, 1], 1000))]),
|
||||
},
|
||||
)]);
|
||||
let mut queue = PendingHashQueue::new(4, HASH_QUEUE_TTL);
|
||||
let now = Instant::now();
|
||||
|
||||
for port in 1001..=1013 {
|
||||
scheduler.enqueue(&mut queue, &mut in_flight, event("hash", port, now));
|
||||
}
|
||||
scheduler.enqueue(&mut queue, &mut in_flight, event("hash", 1001, now));
|
||||
|
||||
let mut received = Vec::new();
|
||||
while let Ok(peer) = peer_rx.try_recv() {
|
||||
received.push(peer.addr.port());
|
||||
}
|
||||
assert_eq!(received, (1001..=1011).collect::<Vec<_>>());
|
||||
assert_eq!(
|
||||
in_flight["hash"].scheduled_peers.len(),
|
||||
MAX_METADATA_PEERS_PER_HASH
|
||||
);
|
||||
assert!(queue.is_empty());
|
||||
let snapshot = stats.snapshot();
|
||||
assert_eq!(snapshot.metadata_live_peer_joins, 11);
|
||||
assert_eq!(snapshot.metadata_queue_deduplicated, 14);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn peer_race_returns_first_success_and_cancels_remaining_fetches() {
|
||||
let now = Instant::now();
|
||||
let peers = vec![
|
||||
PeerCandidate {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 1000)),
|
||||
discovered_at: now,
|
||||
},
|
||||
PeerCandidate {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 1001)),
|
||||
discovered_at: now,
|
||||
},
|
||||
PeerCandidate {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 1002)),
|
||||
discovered_at: now,
|
||||
},
|
||||
];
|
||||
let started = Arc::new(AtomicUsize::new(0));
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(peers.len()));
|
||||
let started_for_race = started.clone();
|
||||
let cancelled_for_race = cancelled.clone();
|
||||
let (_peer_tx, mut peer_rx) = mpsc::channel(MAX_METADATA_PEERS_PER_HASH);
|
||||
let stats = DhtRuntimeStats::default();
|
||||
|
||||
let result = race_peer_fetches(
|
||||
peers,
|
||||
&mut peer_rx,
|
||||
move |peer| {
|
||||
let started = started_for_race.clone();
|
||||
let cancelled = cancelled_for_race.clone();
|
||||
let barrier = barrier.clone();
|
||||
async move {
|
||||
started.fetch_add(1, Ordering::Relaxed);
|
||||
barrier.wait().await;
|
||||
match peer.addr.port() {
|
||||
1000 => {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
(
|
||||
peer,
|
||||
MetadataFetchOutcome::Fetched((
|
||||
"winner".to_string(),
|
||||
1,
|
||||
Vec::new(),
|
||||
0,
|
||||
)),
|
||||
)
|
||||
}
|
||||
1001 => {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
(peer, MetadataFetchOutcome::Failed)
|
||||
}
|
||||
_ => {
|
||||
let _signal = DropSignal(cancelled);
|
||||
std::future::pending::<()>().await;
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
&stats,
|
||||
)
|
||||
.await
|
||||
.expect("one peer should win the race");
|
||||
|
||||
assert_eq!(result.0.addr.port(), 1000);
|
||||
assert_eq!(result.1.0, "winner");
|
||||
assert!(cancelled.load(Ordering::Relaxed));
|
||||
let snapshot = stats.snapshot();
|
||||
assert_eq!(snapshot.metadata_races_started, 1);
|
||||
assert_eq!(snapshot.metadata_peer_candidates, 3);
|
||||
assert_eq!(snapshot.metadata_peer_canceled, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_peer_joins_running_race_and_cancels_slow_initial_peer() {
|
||||
let now = Instant::now();
|
||||
let initial = vec![PeerCandidate {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 1000)),
|
||||
discovered_at: now,
|
||||
}];
|
||||
let live = PeerCandidate {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 1001)),
|
||||
discovered_at: now,
|
||||
};
|
||||
let (peer_tx, mut peer_rx) = mpsc::channel(MAX_METADATA_PEERS_PER_HASH);
|
||||
let initial_started = Arc::new(tokio::sync::Notify::new());
|
||||
let initial_started_for_fetch = initial_started.clone();
|
||||
let initial_cancelled = Arc::new(AtomicBool::new(false));
|
||||
let initial_cancelled_for_fetch = initial_cancelled.clone();
|
||||
let stats = DhtRuntimeStats::default();
|
||||
let sender = tokio::spawn(async move {
|
||||
initial_started.notified().await;
|
||||
peer_tx.send(live).await.unwrap();
|
||||
});
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(1),
|
||||
race_peer_fetches(
|
||||
initial,
|
||||
&mut peer_rx,
|
||||
move |peer| {
|
||||
let initial_started = initial_started_for_fetch.clone();
|
||||
let initial_cancelled = initial_cancelled_for_fetch.clone();
|
||||
async move {
|
||||
if peer.addr.port() == 1000 {
|
||||
let _signal = DropSignal(initial_cancelled);
|
||||
initial_started.notify_one();
|
||||
std::future::pending::<()>().await;
|
||||
unreachable!()
|
||||
}
|
||||
(
|
||||
peer,
|
||||
MetadataFetchOutcome::Fetched((
|
||||
"live-winner".to_string(),
|
||||
1,
|
||||
Vec::new(),
|
||||
0,
|
||||
)),
|
||||
)
|
||||
}
|
||||
},
|
||||
&stats,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("live peer should win");
|
||||
sender.await.unwrap();
|
||||
|
||||
assert_eq!(result.0.addr.port(), 1001);
|
||||
assert!(initial_cancelled.load(Ordering::Relaxed));
|
||||
let snapshot = stats.snapshot();
|
||||
assert_eq!(snapshot.metadata_peer_candidates, 2);
|
||||
assert_eq!(snapshot.metadata_peer_canceled, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -844,7 +1223,14 @@ mod tests {
|
||||
);
|
||||
|
||||
let in_flight = HashMap::new();
|
||||
let newest = queue.pop_newest_available(&in_flight).unwrap();
|
||||
assert!(
|
||||
queue
|
||||
.pop_newest_ready(start + Duration::from_millis(24), &in_flight)
|
||||
.is_none()
|
||||
);
|
||||
let newest = queue
|
||||
.pop_newest_ready(start + Duration::from_secs(2), &in_flight)
|
||||
.unwrap();
|
||||
assert_eq!(newest.info_hash, "newer");
|
||||
assert_eq!(queue.expire(start + Duration::from_secs(11)), 1);
|
||||
assert!(queue.is_empty());
|
||||
|
||||
+34
-5
@@ -6,11 +6,15 @@ use crate::error::Result;
|
||||
use crate::krpc::encode_response;
|
||||
use crate::metadata::RbitFetcher;
|
||||
use crate::node_id::{neighbor_node_id, random_node_id, transaction_id_from_bytes};
|
||||
use crate::peer_lookup::{
|
||||
PeerLookupHandle, PeerLookupRuntime, is_peer_lookup_tid, spawn_peer_lookup,
|
||||
};
|
||||
use crate::protocol::{DhtArgs, DhtMessage};
|
||||
use crate::runtime_stats::{DhtRuntimeLimits, DhtRuntimeStats};
|
||||
use crate::scheduler::{
|
||||
MetadataCompletionCallback, MetadataFetchCallback, MetadataScheduler,
|
||||
MetadataSchedulerCallbacks, MetadataSchedulerLimits, TorrentAckCallback,
|
||||
MetadataSchedulerCallbacks, MetadataSchedulerLimits, MetadataSchedulerRuntime,
|
||||
TorrentAckCallback,
|
||||
};
|
||||
use crate::types::{DHTOptions, MetadataFetchCompletion, NetMode, NodeTuple, TorrentInfo};
|
||||
use crate::udp_buffer::UdpBufferPool;
|
||||
@@ -252,6 +256,7 @@ pub struct DHTServer {
|
||||
metadata_completion_callback: Arc<ArcSwapOption<MetadataCompletionCallback>>,
|
||||
error_callback: Arc<ArcSwapOption<ErrorCallback>>,
|
||||
crawl_engine: Arc<CrawlEngine>,
|
||||
peer_lookup: PeerLookupHandle,
|
||||
hash_events_tx: mpsc::Sender<HashDiscovered>,
|
||||
metadata_queue_len: Arc<AtomicUsize>,
|
||||
max_metadata_queue_size: usize,
|
||||
@@ -352,8 +357,24 @@ impl DHTServer {
|
||||
let metadata_completion_callback = Arc::new(ArcSwapOption::empty());
|
||||
let metadata_queue_len = Arc::new(AtomicUsize::new(0));
|
||||
let shutdown = CancellationToken::new();
|
||||
let crawl_engine = Arc::new(CrawlEngine::new(
|
||||
crawl_config.clone(),
|
||||
runtime_stats.clone(),
|
||||
));
|
||||
let peer_lookup = spawn_peer_lookup(
|
||||
options.netmode,
|
||||
node_id,
|
||||
&sockets_by_bind_addr,
|
||||
crawl_engine.snapshot.clone(),
|
||||
hash_events_tx.clone(),
|
||||
PeerLookupRuntime {
|
||||
options: options.peer_lookup.clone(),
|
||||
stats: runtime_stats.clone(),
|
||||
shutdown: shutdown.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let scheduler = MetadataScheduler::new_with_runtime_stats(
|
||||
let scheduler = MetadataScheduler::new_with_runtime(
|
||||
hash_rx,
|
||||
fetcher,
|
||||
MetadataSchedulerLimits {
|
||||
@@ -367,7 +388,10 @@ impl DHTServer {
|
||||
},
|
||||
metadata_queue_len.clone(),
|
||||
shutdown.clone(),
|
||||
runtime_stats.clone(),
|
||||
MetadataSchedulerRuntime {
|
||||
stats: runtime_stats.clone(),
|
||||
peer_lookup_tx: Some(peer_lookup.request_sender()),
|
||||
},
|
||||
);
|
||||
tokio::spawn(scheduler.run());
|
||||
|
||||
@@ -383,7 +407,8 @@ impl DHTServer {
|
||||
on_metadata_fetch,
|
||||
metadata_completion_callback,
|
||||
error_callback: Arc::new(ArcSwapOption::empty()),
|
||||
crawl_engine: Arc::new(CrawlEngine::new(crawl_config, runtime_stats.clone())),
|
||||
crawl_engine,
|
||||
peer_lookup,
|
||||
hash_events_tx,
|
||||
metadata_queue_len,
|
||||
max_metadata_queue_size,
|
||||
@@ -650,7 +675,11 @@ impl DHTServer {
|
||||
if let Some(response) = msg.r
|
||||
&& let Some(tid) = transaction_id_from_bytes(&msg.t)
|
||||
{
|
||||
self.crawl_engine.route_response(remote_addr, tid, response);
|
||||
if is_peer_lookup_tid(&tid) {
|
||||
self.peer_lookup.route_response(remote_addr, tid, response);
|
||||
} else {
|
||||
self.crawl_engine.route_response(remote_addr, tid, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -117,6 +117,8 @@ pub struct DHTOptions {
|
||||
pub hash_queue_capacity: usize,
|
||||
/// Metadata download and Peer-cache limits.
|
||||
pub metadata: MetadataOptions,
|
||||
/// Active get_peers lookup rate and concurrency limits.
|
||||
pub peer_lookup: PeerLookupOptions,
|
||||
/// Active crawl, node-pool and scheduler limits.
|
||||
pub crawl: CrawlOptions,
|
||||
}
|
||||
@@ -136,6 +138,17 @@ pub struct MetadataOptions {
|
||||
pub peer_failure_ttl_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Active get_peers lookup budgets used to discover additional Metadata Peers.
|
||||
pub struct PeerLookupOptions {
|
||||
/// Maximum new InfoHash lookups started per second. Zero disables active lookup.
|
||||
pub max_lookups_per_second: u32,
|
||||
/// Maximum lookup budget consumed immediately after an idle period.
|
||||
pub burst: u32,
|
||||
/// Maximum InfoHash lookups kept active at the same time.
|
||||
pub max_active_lookups: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// Active crawl configuration grouped by responsibility.
|
||||
pub struct CrawlOptions {
|
||||
@@ -243,6 +256,7 @@ impl Default for DHTOptions {
|
||||
netmode: NetMode::Ipv4Only,
|
||||
hash_queue_capacity: 10_000,
|
||||
metadata: MetadataOptions::default(),
|
||||
peer_lookup: PeerLookupOptions::default(),
|
||||
crawl: CrawlOptions::default(),
|
||||
}
|
||||
}
|
||||
@@ -260,6 +274,16 @@ impl Default for MetadataOptions {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PeerLookupOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_lookups_per_second: 32,
|
||||
burst: 32,
|
||||
max_active_lookups: 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimitOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
Reference in New Issue
Block a user