feat: implement BEP-51 InfoHash sampling and related metrics

- Added new metrics for BEP-51 InfoHash sampling in `metrics.md`.
- Enhanced logging in `main.rs` to include BEP-51 sampling statistics.
- Introduced new fields in `crawl_engine.rs` to manage sampling state.
- Implemented encoding for BEP-51 sampling queries in `krpc.rs`.
- Created a new module `sample_infohashes.rs` to handle BEP-51 sampling logic.
- Updated `peer_lookup.rs` to support requests for sampled InfoHashes.
- Modified `scheduler.rs` to send `PeerLookupRequest` instead of raw info hashes.
- Integrated BEP-51 sampling into the DHT server in `server.rs`.
- Added configuration options for BEP-51 sampling in `types.rs`.
- Updated runtime statistics to track BEP-51 sampling metrics in `runtime_stats.rs`.
This commit is contained in:
chuan
2026-08-05 23:28:31 +08:00
parent 95fdcbeefd
commit b9b157720d
14 changed files with 790 additions and 55 deletions
+15
View File
@@ -2,6 +2,21 @@
本项目遵循语义化版本。0.2.1 是包含公开 API 变更的 breaking release。
## Unreleased
### Added
- BEP-51 `sample_infohashes` 主动发现、按节点 interval/退避的采样 actor,以及有界 Hash
去重。
- 采样 Hash 优先查询来源节点,再通过迭代式 `get_peers` 补充 Peer。
### Changed
- 主动 `get_peers` ingress 改为有界排队,避免突发采样在速率预算耗尽时直接丢弃。
- 默认主动 Peer lookup 提升到每秒 128 个、最多 256 个并发 lookup。
- 空节点池的 Bootstrap 默认改为 30 秒重试、每轮最多 16 个端点,降低坏 DNS
地址导致冷启动停滞的概率。
## 0.2.1 - 2026-07-30
### Breaking changes
+7 -3
View File
@@ -4,13 +4,14 @@
[![Documentation](https://docs.rs/dht-crawler/badge.svg)](https://docs.rs/dht-crawler)
[![License](https://img.shields.io/crates/l/dht-crawler.svg)](LICENSE)
基于 Rust 和 Tokio 的 BitTorrent DHT 爬虫库。它参与 BEP-5 DHT 网络,接收
`announce_peer`,并通过 BEP-9 `ut_metadata` 获取、校验和解析 torrent 元数据。
基于 Rust 和 Tokio 的 BitTorrent DHT 爬虫库。它参与 BEP-5 DHT 网络,通过 BEP-51
`sample_infohashes` 主动发现 InfoHash,也接收 `announce_peer`,并通过 BEP-9
`ut_metadata` 获取、校验和解析 torrent 元数据。
`dht-crawler` 提供:
- IPv4、IPv6 和双栈 DHT
- 主动节点发现与 `get_peers` 查询;
- 主动节点发现、BEP-51 InfoHash 采样`get_peers` 查询;
- 有界、去重的 Metadata 下载队列;
- InfoHash 过滤、异步准入、结果交付和完成通知;
- 默认可用的运行时统计,以及可选的 `metrics` 集成。
@@ -177,6 +178,7 @@ let options = DHTOptions {
| `DHTOptions` | 监听端口、网络模式和顶层队列 |
| `MetadataOptions` | 下载超时、队列、并发和失败 Peer 缓存 |
| `PeerLookupOptions` | 主动 `get_peers` 的速率与并发 |
| `SampleInfohashesOptions` | BEP-51 采样速率、并发、超时、退避和 Hash 去重容量 |
| `RateLimitOptions` | `find_node`、在途请求和 UDP 回复预算 |
| `PoolOptions` | 节点池、最近探测记录和响应节点缓存 |
| `BootstrapOptions` | Bootstrap 节点与失败退避 |
@@ -188,8 +190,10 @@ let options = DHTOptions {
- `DHTOptions::default()` 使用 `Ipv4Only`
- `NetMode::DualStack` 会分别绑定 IPv4 和 IPv6 Socket
- `DHTServer::new()` 会立即在所有可用接口上绑定配置的 UDP 端口;
- 空节点池默认每 30 秒重新尝试 Bootstrap,每轮最多使用 16 个已解析端点;
- `MetadataOptions::timeout_secs` 是单个 Peer 尝试的端到端期限;
- `PeerLookupOptions::max_lookups_per_second = 0` 会关闭主动 `get_peers`
- `SampleInfohashesOptions::max_queries_per_second = 0` 会关闭 BEP-51 主动采样;
- Metadata 和爬取队列都是有界的,容量应与下游处理能力一起调整。
## 数据与运行语义
+11
View File
@@ -45,6 +45,17 @@ recorder、不监听端口,也不依赖任何特定导出协议;Prometheus e
actor 每秒把内部增量 flush 到 counter,因此 exporter 看到的 counter 可能最多延迟约一秒。
## BEP-51 InfoHash 采样
| 指标 | 类型 | 标签 | 含义 |
|---|---|---|---|
| `dht_sample_infohashes_queries_total` | counter | — | 已发送的 BEP-51 查询 |
| `dht_sample_infohashes_responses_total` | counter | — | 匹配的 BEP-51 响应 |
| `dht_sample_infohashes_timeouts_total` | counter | — | 超时的 BEP-51 请求 |
| `dht_sample_infohashes_hashes_total` | counter | — | 已接受并送往 Peer lookup 的新 Hash |
| `dht_sample_infohashes_in_flight` | gauge | — | 当前在途采样请求 |
| `dht_sample_infohashes_dropped_total` | counter | `reason=response_queue_full|peer_lookup_queue_full` | 有界队列丢弃 |
## announce 与 Metadata ingress
| 指标 | 类型 | 标签 | 含义 |
+10 -1
View File
@@ -111,10 +111,19 @@ async fn main() -> Result<()> {
// ✅ 监控:爬虫运行状态
log::info!(
"📊 [监控] 时长: {}s | 成功抓取: ✨ {} | 节点: {} | Metadata: {}/{} | worker: {}",
"📊 [监控] 时长: {}s | 成功抓取: ✨ {} | 节点: {} | BEP51: hash={}, resp={}, timeout={} | Lookup: peer={} | Fetch: ok={}, fail={}, connect={}, timeout={}, noext={} | Metadata: {}/{} | worker: {}",
uptime,
success_fetch,
runtime.node_pool_size,
runtime.sample_infohashes_hashes_discovered,
runtime.sample_infohashes_responses,
runtime.sample_infohashes_timeouts,
runtime.peer_lookup_peers_found,
runtime.metadata_peer_succeeded,
runtime.metadata_peer_failed,
runtime.metadata_connect_failed,
runtime.metadata_peer_timeouts,
runtime.metadata_no_extension,
runtime.metadata_queue_depth,
runtime.metadata_queue_max,
runtime.metadata_in_flight,
+3
View File
@@ -914,6 +914,9 @@ mod tests {
nodes: None,
nodes6: None,
values: None,
samples: None,
num: None,
interval: 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());
+33
View File
@@ -118,6 +118,23 @@ pub(crate) fn encode_get_peers_query(
buffer.extend_from_slice(b"1:y1:qe");
}
pub(crate) fn encode_sample_infohashes_query(
buffer: &mut BytesMut,
tid: &TransactionId,
target: &[u8; 20],
sender_id: &[u8; 20],
) {
buffer.clear();
buffer.reserve(128);
buffer.extend_from_slice(b"d1:ad2:id20:");
buffer.extend_from_slice(sender_id);
buffer.extend_from_slice(b"6:target20:");
buffer.extend_from_slice(target);
buffer.extend_from_slice(b"e1:q17:sample_infohashes1:t8:");
buffer.extend_from_slice(tid);
buffer.extend_from_slice(b"1:y1:qe");
}
pub(crate) fn encode_response(
buffer: &mut BytesMut,
tid: &[u8],
@@ -240,6 +257,19 @@ mod tests {
assert_eq!(message.a.unwrap().info_hash.unwrap().as_ref(), &info_hash);
}
#[test]
fn manual_sample_infohashes_encoding_round_trips() {
let mut buffer = BytesMut::new();
let tid = [1; 8];
let target = [2; 20];
let sender = [3; 20];
encode_sample_infohashes_query(&mut buffer, &tid, &target, &sender);
let message: DhtMessage = serde_bencode::from_bytes(&buffer).unwrap();
assert_eq!(message.t.as_ref(), &tid);
assert_eq!(message.q.as_deref(), Some("sample_infohashes"));
assert_eq!(message.a.unwrap().target.unwrap().as_ref(), &target);
}
#[test]
fn manual_response_encoding_round_trips() {
let mut buffer = BytesMut::new();
@@ -264,6 +294,9 @@ mod tests {
serde_bytes::ByteBuf::from(vec![10, 0, 0, 1, 0x1a, 0xe1]),
serde_bytes::ByteBuf::from(vec![1, 2, 3]),
]),
samples: None,
num: None,
interval: None,
};
let mut peers = Vec::new();
assert_eq!(
+5 -2
View File
@@ -21,6 +21,7 @@ mod peer_lookup;
pub mod protocol;
mod routing_snapshot;
mod runtime_stats;
mod sample_infohashes;
/// Bounded, deduplicating Metadata scheduler.
pub mod scheduler;
mod server;
@@ -38,7 +39,8 @@ pub use server::{DHTServer, HashDiscovered};
pub use types::{
BootstrapOptions, CrawlOptions, DHTOptions, FileInfo, MetadataFetchCompletion,
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PeerLookupOptions,
PoolOptions, RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, TargetOptions,
TorrentInfo,
};
/// Common server, configuration and callback payload imports.
@@ -52,6 +54,7 @@ pub mod prelude {
pub use crate::types::{
BootstrapOptions, CrawlOptions, DHTOptions, FileInfo, MetadataFetchCompletion,
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PeerLookupOptions,
PoolOptions, RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, TargetOptions,
TorrentInfo,
};
}
+104 -33
View File
@@ -27,7 +27,7 @@ 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 REQUEST_CHANNEL_CAPACITY: usize = 16_384;
const RESPONSE_CHANNEL_CAPACITY: usize = 4_096;
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
@@ -46,6 +46,7 @@ struct LookupState {
info_hash: [u8; 20],
info_hash_hex: String,
frontier: Vec<NodeTuple>,
preferred: Option<NodeTuple>,
seen_nodes: AHashSet<SocketAddr>,
peers: AHashSet<SocketAddr>,
queried: usize,
@@ -55,6 +56,9 @@ struct LookupState {
impl LookupState {
fn pop_closest(&mut self) -> Option<NodeTuple> {
if let Some(preferred) = self.preferred.take() {
return Some(preferred);
}
let index = self
.frontier
.iter()
@@ -78,9 +82,24 @@ struct LookupResponse {
response: DhtResponse,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct PeerLookupRequest {
pub(crate) info_hash: [u8; 20],
pub(crate) preferred_node: Option<NodeTuple>,
}
impl PeerLookupRequest {
pub(crate) fn new(info_hash: [u8; 20]) -> Self {
Self {
info_hash,
preferred_node: None,
}
}
}
#[derive(Clone)]
pub(crate) struct PeerLookupHandle {
request_tx: mpsc::Sender<[u8; 20]>,
request_tx: mpsc::Sender<PeerLookupRequest>,
response_tx: mpsc::Sender<LookupResponse>,
runtime_stats: DhtRuntimeStats,
}
@@ -92,7 +111,7 @@ pub(crate) struct PeerLookupRuntime {
}
impl PeerLookupHandle {
pub(crate) fn request_sender(&self) -> mpsc::Sender<[u8; 20]> {
pub(crate) fn request_sender(&self) -> mpsc::Sender<PeerLookupRequest> {
self.request_tx.clone()
}
@@ -160,6 +179,10 @@ pub(crate) fn spawn_peer_lookup(
Instant::now(),
),
max_active_lookups: options.max_active_lookups,
enabled: options.max_lookups_per_second > 0 && options.max_active_lookups > 0,
queued: VecDeque::new(),
queued_hashes: AHashSet::new(),
active_hashes: AHashSet::new(),
active: AHashMap::new(),
pending: AHashMap::new(),
pending_expiry: VecDeque::new(),
@@ -183,10 +206,14 @@ struct PeerLookupActor {
socket_v6: Option<Arc<UdpSocket>>,
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
hash_tx: mpsc::Sender<HashDiscovered>,
request_rx: mpsc::Receiver<[u8; 20]>,
request_rx: mpsc::Receiver<PeerLookupRequest>,
response_rx: mpsc::Receiver<LookupResponse>,
request_budget: RateBucket,
max_active_lookups: usize,
enabled: bool,
queued: VecDeque<PeerLookupRequest>,
queued_hashes: AHashSet<[u8; 20]>,
active_hashes: AHashSet<[u8; 20]>,
active: AHashMap<u64, LookupState>,
pending: AHashMap<PendingKey, PendingQuery>,
pending_expiry: VecDeque<(Instant, PendingKey)>,
@@ -210,47 +237,83 @@ impl PeerLookupActor {
}
_ = 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;
let Some(request) = request else { break };
self.queue_request(request);
}
}
}
}
async fn start_lookup(&mut self, info_hash: [u8; 20], now: Instant) {
fn queue_request(&mut self, request: PeerLookupRequest) {
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);
if !self.enabled {
return;
}
if self.queued_hashes.contains(&request.info_hash)
|| self.active_hashes.contains(&request.info_hash)
{
return;
}
if self.queued.len() >= REQUEST_CHANNEL_CAPACITY {
self.runtime_stats.peer_lookup_rate_limited();
#[cfg(feature = "metrics")]
counter!("dht_peer_lookup_dropped_total", "reason" => "request_queue_full")
.increment(1);
return;
}
self.queued_hashes.insert(request.info_hash);
self.queued.push_back(request);
}
async fn start_queued(&mut self, now: Instant) {
while self.active.len() < self.max_active_lookups
&& !self.queued.is_empty()
&& self.request_budget.try_take_one(now)
{
let request = self.queued.pop_front().expect("queued request exists");
self.queued_hashes.remove(&request.info_hash);
if !self.start_lookup(request, now).await {
self.request_budget.refund_one();
}
}
}
async fn start_lookup(&mut self, request: PeerLookupRequest, now: Instant) -> bool {
let info_hash = request.info_hash;
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 =
let mut frontier =
self.snapshot
.load()
.closest_nodes(&info_hash, MAX_QUERIES_PER_LOOKUP, filter_ipv6);
if frontier.is_empty() {
let preferred = request.preferred_node;
if let Some(preferred) = preferred {
frontier.retain(|node| node.addr != preferred.addr);
}
if frontier.is_empty() && preferred.is_none() {
self.runtime_stats.peer_lookup_empty();
return;
return false;
}
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();
let mut seen_nodes: AHashSet<_> = frontier.iter().map(|node| node.addr).collect();
if let Some(preferred) = preferred {
seen_nodes.insert(preferred.addr);
}
self.active.insert(
lookup_id,
LookupState {
info_hash,
info_hash_hex: hex::encode(info_hash),
frontier,
preferred,
seen_nodes,
peers: AHashSet::new(),
queried: 0,
@@ -258,29 +321,27 @@ impl PeerLookupActor {
deadline: now + LOOKUP_TIMEOUT,
},
);
self.active_hashes.insert(info_hash);
self.runtime_stats.peer_lookup_started();
#[cfg(feature = "metrics")]
counter!("dht_peer_lookup_started_total").increment(1);
self.dispatch_more(lookup_id, now).await;
true
}
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;
};
while 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))
}) {
let tid = self.next_transaction_id();
let key = PendingKey {
addr: node.addr,
@@ -410,6 +471,7 @@ impl PeerLookupActor {
for lookup_id in expired {
self.finish_lookup(lookup_id);
}
self.start_queued(now).await;
}
fn finish_if_complete(&mut self, lookup_id: u64, now: Instant) {
@@ -423,7 +485,9 @@ impl PeerLookupActor {
}
fn finish_lookup(&mut self, lookup_id: u64) {
self.active.remove(&lookup_id);
if let Some(state) = self.active.remove(&lookup_id) {
self.active_hashes.remove(&state.info_hash);
}
self.pending
.retain(|_, pending| pending.lookup_id != lookup_id);
}
@@ -477,7 +541,11 @@ mod tests {
shutdown: shutdown.clone(),
},
);
handle.request_tx.send([3; 20]).await.unwrap();
handle
.request_tx
.send(PeerLookupRequest::new([3; 20]))
.await
.unwrap();
let mut buffer = [0u8; 512];
let (len, source) =
@@ -499,6 +567,9 @@ mod tests {
values: Some(vec![serde_bytes::ByteBuf::from(vec![
8, 8, 4, 4, 0x1a, 0xe1,
])]),
samples: None,
num: None,
interval: None,
},
);
+9
View File
@@ -51,4 +51,13 @@ pub struct DhtResponse {
#[serde(default)]
/// Compact Peer endpoints returned by `get_peers`.
pub values: Option<Vec<serde_bytes::ByteBuf>>,
#[serde(default)]
/// BEP-51 concatenated 20-byte sampled InfoHashes.
pub samples: Option<serde_bytes::ByteBuf>,
#[serde(default)]
/// BEP-51 estimated number of InfoHashes held by the responder.
pub num: Option<u64>,
#[serde(default)]
/// BEP-51 requested delay before sampling this node again, in seconds.
pub interval: Option<u64>,
}
+114
View File
@@ -246,6 +246,22 @@ pub struct DhtRuntimeSnapshot {
pub peer_lookup_response_dropped: u64,
/// Discovered Peer endpoints dropped because Hash ingress was full.
pub peer_lookup_output_dropped: u64,
/// BEP-51 requests sent.
pub sample_infohashes_queries: u64,
/// Matched BEP-51 responses.
pub sample_infohashes_responses: u64,
/// BEP-51 requests that timed out.
pub sample_infohashes_timeouts: u64,
/// BEP-51 UDP sends that failed immediately.
pub sample_infohashes_send_failures: u64,
/// BEP-51 responses dropped before reaching the sampler actor.
pub sample_infohashes_response_dropped: u64,
/// New sampled hashes accepted for Peer lookup.
pub sample_infohashes_hashes_discovered: u64,
/// Sampled hashes rejected by the bounded deduplicator.
pub sample_infohashes_hashes_duplicate: u64,
/// New sampled hashes dropped because Peer lookup ingress was full.
pub sample_infohashes_hashes_dropped: u64,
/// Real Peer network attempts.
pub metadata_peer_attempts: u64,
/// Successful Peer downloads and parses.
@@ -340,6 +356,14 @@ struct DhtRuntimeStatsInner {
peer_lookup_peers_found: AtomicU64,
peer_lookup_response_dropped: AtomicU64,
peer_lookup_output_dropped: AtomicU64,
sample_infohashes_queries: AtomicU64,
sample_infohashes_responses: AtomicU64,
sample_infohashes_timeouts: AtomicU64,
sample_infohashes_send_failures: AtomicU64,
sample_infohashes_response_dropped: AtomicU64,
sample_infohashes_hashes_discovered: AtomicU64,
sample_infohashes_hashes_duplicate: AtomicU64,
sample_infohashes_hashes_dropped: AtomicU64,
metadata_peer_attempts: AtomicU64,
metadata_peer_succeeded: AtomicU64,
metadata_peer_failed: AtomicU64,
@@ -442,6 +466,14 @@ impl Default for DhtRuntimeStatsInner {
peer_lookup_peers_found: AtomicU64::new(0),
peer_lookup_response_dropped: AtomicU64::new(0),
peer_lookup_output_dropped: AtomicU64::new(0),
sample_infohashes_queries: AtomicU64::new(0),
sample_infohashes_responses: AtomicU64::new(0),
sample_infohashes_timeouts: AtomicU64::new(0),
sample_infohashes_send_failures: AtomicU64::new(0),
sample_infohashes_response_dropped: AtomicU64::new(0),
sample_infohashes_hashes_discovered: AtomicU64::new(0),
sample_infohashes_hashes_duplicate: AtomicU64::new(0),
sample_infohashes_hashes_dropped: AtomicU64::new(0),
metadata_peer_attempts: AtomicU64::new(0),
metadata_peer_succeeded: AtomicU64::new(0),
metadata_peer_failed: AtomicU64::new(0),
@@ -569,6 +601,24 @@ impl DhtRuntimeStats {
.peer_lookup_response_dropped
.load(Ordering::Relaxed),
peer_lookup_output_dropped: inner.peer_lookup_output_dropped.load(Ordering::Relaxed),
sample_infohashes_queries: inner.sample_infohashes_queries.load(Ordering::Relaxed),
sample_infohashes_responses: inner.sample_infohashes_responses.load(Ordering::Relaxed),
sample_infohashes_timeouts: inner.sample_infohashes_timeouts.load(Ordering::Relaxed),
sample_infohashes_send_failures: inner
.sample_infohashes_send_failures
.load(Ordering::Relaxed),
sample_infohashes_response_dropped: inner
.sample_infohashes_response_dropped
.load(Ordering::Relaxed),
sample_infohashes_hashes_discovered: inner
.sample_infohashes_hashes_discovered
.load(Ordering::Relaxed),
sample_infohashes_hashes_duplicate: inner
.sample_infohashes_hashes_duplicate
.load(Ordering::Relaxed),
sample_infohashes_hashes_dropped: inner
.sample_infohashes_hashes_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),
@@ -807,6 +857,54 @@ impl DhtRuntimeStats {
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_query(&self) {
self.inner
.sample_infohashes_queries
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_response(&self) {
self.inner
.sample_infohashes_responses
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_timeout(&self) {
self.inner
.sample_infohashes_timeouts
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_send_failed(&self) {
self.inner
.sample_infohashes_send_failures
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_response_dropped(&self) {
self.inner
.sample_infohashes_response_dropped
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_hash_discovered(&self) {
self.inner
.sample_infohashes_hashes_discovered
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_hash_duplicate(&self) {
self.inner
.sample_infohashes_hashes_duplicate
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn sample_hash_dropped(&self) {
self.inner
.sample_infohashes_hashes_dropped
.fetch_add(1, Ordering::Relaxed);
}
pub(crate) fn metadata_peer_attempt(&self) {
self.inner
.metadata_peer_attempts
@@ -1133,6 +1231,14 @@ mod tests {
writer.peer_lookup_peer_found();
writer.peer_lookup_response_dropped();
writer.peer_lookup_output_dropped();
writer.sample_query();
writer.sample_response();
writer.sample_timeout();
writer.sample_send_failed();
writer.sample_response_dropped();
writer.sample_hash_discovered();
writer.sample_hash_duplicate();
writer.sample_hash_dropped();
writer.metadata_peer_attempt();
writer.metadata_peer_succeeded();
writer.metadata_peer_failed();
@@ -1192,6 +1298,14 @@ mod tests {
peer_lookup_peers_found: 1,
peer_lookup_response_dropped: 1,
peer_lookup_output_dropped: 1,
sample_infohashes_queries: 1,
sample_infohashes_responses: 1,
sample_infohashes_timeouts: 1,
sample_infohashes_send_failures: 1,
sample_infohashes_response_dropped: 1,
sample_infohashes_hashes_discovered: 1,
sample_infohashes_hashes_duplicate: 1,
sample_infohashes_hashes_dropped: 1,
metadata_peer_attempts: 1,
metadata_peer_succeeded: 1,
metadata_peer_failed: 1,
+410
View File
@@ -0,0 +1,410 @@
use crate::budget::RateBucket;
use crate::crawl_engine::CrawlEngine;
use crate::krpc::{encode_sample_infohashes_query, for_each_response_node};
use crate::node_id::{TransactionId, random_node_id};
use crate::peer_lookup::{PeerLookupHandle, PeerLookupRequest};
use crate::protocol::DhtResponse;
use crate::routing_snapshot::RoutingSnapshot;
use crate::runtime_stats::DhtRuntimeStats;
use crate::types::{NetMode, NodeTuple, SampleInfohashesOptions};
use ahash::{AHashMap, AHashSet};
use arc_swap::ArcSwap;
use bytes::BytesMut;
#[cfg(feature = "metrics")]
use metrics::{counter, gauge};
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 SAMPLE_TID_TAG: u8 = 0x51;
const RESPONSE_CHANNEL_CAPACITY: usize = 4_096;
const MAINTENANCE_INTERVAL: Duration = Duration::from_millis(25);
const PRODUCTIVE_REVISIT: Duration = Duration::from_secs(60);
const MAX_PROTOCOL_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
struct PendingKey {
addr: SocketAddr,
tid: TransactionId,
}
#[derive(Debug, Clone, Copy)]
struct PendingRequest {
deadline: Instant,
}
struct SampleResponse {
remote_addr: SocketAddr,
tid: TransactionId,
response: DhtResponse,
}
#[derive(Clone)]
pub(crate) struct SampleInfohashesHandle {
response_tx: mpsc::Sender<SampleResponse>,
runtime_stats: DhtRuntimeStats,
}
impl SampleInfohashesHandle {
pub(crate) fn route_response(
&self,
remote_addr: SocketAddr,
tid: TransactionId,
response: DhtResponse,
) {
if self
.response_tx
.try_send(SampleResponse {
remote_addr,
tid,
response,
})
.is_err()
{
self.runtime_stats.sample_response_dropped();
#[cfg(feature = "metrics")]
counter!("dht_sample_infohashes_dropped_total", "reason" => "response_queue_full")
.increment(1);
}
}
}
pub(crate) fn is_sample_infohashes_tid(tid: &TransactionId) -> bool {
tid[0] == SAMPLE_TID_TAG
}
pub(crate) struct SampleInfohashesRuntime {
pub(crate) options: SampleInfohashesOptions,
pub(crate) stats: DhtRuntimeStats,
pub(crate) shutdown: CancellationToken,
}
pub(crate) fn spawn_sample_infohashes(
netmode: NetMode,
local_id: [u8; 20],
sockets: &std::collections::HashMap<SocketAddr, Arc<UdpSocket>>,
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
crawl_engine: Arc<CrawlEngine>,
peer_lookup: PeerLookupHandle,
runtime: SampleInfohashesRuntime,
) -> SampleInfohashesHandle {
let SampleInfohashesRuntime {
options,
stats,
shutdown,
} = runtime;
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 now = Instant::now();
let actor = SampleInfohashesActor {
netmode,
local_id,
socket_v4,
socket_v6,
snapshot,
crawl_engine,
peer_lookup,
response_rx,
query_budget: RateBucket::per_second(
options.max_queries_per_second,
options.burst,
true,
now,
),
max_in_flight: options.max_in_flight,
request_timeout: Duration::from_millis(options.request_timeout_millis.max(100)),
unsupported_backoff: Duration::from_secs(options.unsupported_backoff_secs.max(1)),
dedup_capacity: options.dedup_capacity,
seen_hashes: AHashSet::new(),
seen_order: VecDeque::new(),
next_allowed: AHashMap::new(),
pending_addrs: AHashSet::new(),
pending: AHashMap::new(),
pending_expiry: VecDeque::new(),
next_tid: 1,
runtime_stats: stats.clone(),
shutdown,
};
tokio::spawn(actor.run());
SampleInfohashesHandle {
response_tx,
runtime_stats: stats,
}
}
struct SampleInfohashesActor {
netmode: NetMode,
local_id: [u8; 20],
socket_v4: Option<Arc<UdpSocket>>,
socket_v6: Option<Arc<UdpSocket>>,
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
crawl_engine: Arc<CrawlEngine>,
peer_lookup: PeerLookupHandle,
response_rx: mpsc::Receiver<SampleResponse>,
query_budget: RateBucket,
max_in_flight: usize,
request_timeout: Duration,
unsupported_backoff: Duration,
dedup_capacity: usize,
seen_hashes: AHashSet<[u8; 20]>,
seen_order: VecDeque<[u8; 20]>,
next_allowed: AHashMap<SocketAddr, Instant>,
pending_addrs: AHashSet<SocketAddr>,
pending: AHashMap<PendingKey, PendingRequest>,
pending_expiry: VecDeque<(Instant, PendingKey)>,
next_tid: u64,
runtime_stats: DhtRuntimeStats,
shutdown: CancellationToken,
}
impl SampleInfohashesActor {
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());
}
_ = maintenance.tick() => {
let now = Instant::now();
self.expire(now);
self.dispatch(now).await;
}
}
}
}
async fn dispatch(&mut self, now: Instant) {
if self.max_in_flight == 0 || self.pending.len() >= self.max_in_flight {
return;
}
let available = self.max_in_flight.saturating_sub(self.pending.len());
let budget = self.query_budget.try_take(available, now);
if budget == 0 {
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 candidates = self
.snapshot
.load()
.random_nodes((budget * 8).max(64), filter_ipv6);
let mut sent_count = 0usize;
for node in candidates {
if sent_count >= budget {
break;
}
if self.pending_addrs.contains(&node.addr)
|| self
.next_allowed
.get(&node.addr)
.is_some_and(|deadline| *deadline > now)
{
continue;
}
if self.send_query(node, now).await {
sent_count += 1;
}
}
if sent_count < budget {
self.query_budget.refund(budget - sent_count);
}
}
async fn send_query(&mut self, node: NodeTuple, now: Instant) -> bool {
let socket = if node.addr.is_ipv4() {
self.socket_v4.clone()
} else {
self.socket_v6.clone()
};
let Some(socket) = socket else {
return false;
};
let tid = self.next_transaction_id();
let mut buffer = BytesMut::with_capacity(128);
encode_sample_infohashes_query(&mut buffer, &tid, &random_node_id(), &self.local_id);
if socket.send_to(&buffer, node.addr).await.is_err() {
self.runtime_stats.sample_send_failed();
self.next_allowed
.insert(node.addr, now + self.unsupported_backoff);
return false;
}
let key = PendingKey {
addr: node.addr,
tid,
};
let deadline = now + self.request_timeout;
self.pending.insert(key, PendingRequest { deadline });
self.pending_expiry.push_back((deadline, key));
self.pending_addrs.insert(node.addr);
self.runtime_stats.udp_sent(buffer.len());
self.runtime_stats.sample_query();
#[cfg(feature = "metrics")]
{
counter!("dht_sample_infohashes_queries_total").increment(1);
gauge!("dht_sample_infohashes_in_flight").set(self.pending.len() as f64);
}
true
}
fn handle_response(&mut self, event: SampleResponse, now: Instant) {
let key = PendingKey {
addr: event.remote_addr,
tid: event.tid,
};
if self.pending.remove(&key).is_none() {
return;
}
self.pending_addrs.remove(&event.remote_addr);
self.runtime_stats.sample_response();
for_each_response_node(&event.response, self.netmode, |node| {
self.crawl_engine.route_discovered(node)
});
let responder_id = event
.response
.id
.as_deref()
.and_then(|id| <[u8; 20]>::try_from(id.as_slice()).ok())
.unwrap_or([0; 20]);
let preferred_node = NodeTuple {
id: responder_id,
addr: event.remote_addr,
};
let mut discovered = 0usize;
if let Some(samples) = event.response.samples.as_deref()
&& samples.len() % 20 == 0
{
for chunk in samples.chunks_exact(20) {
let hash: [u8; 20] = chunk.try_into().expect("sample hash is 20 bytes");
if self.seen_hashes.contains(&hash) {
self.runtime_stats.sample_hash_duplicate();
continue;
}
let request = PeerLookupRequest {
info_hash: hash,
preferred_node: Some(preferred_node),
};
if self.peer_lookup.request_sender().try_send(request).is_ok() {
self.remember_hash(hash);
discovered += 1;
self.runtime_stats.sample_hash_discovered();
} else {
self.runtime_stats.sample_hash_dropped();
#[cfg(feature = "metrics")]
counter!("dht_sample_infohashes_dropped_total", "reason" => "peer_lookup_queue_full")
.increment(1);
}
}
}
let protocol_interval = Duration::from_secs(event.response.interval.unwrap_or(300))
.clamp(Duration::from_secs(10), MAX_PROTOCOL_INTERVAL);
let delay = if discovered > 0 {
protocol_interval.min(PRODUCTIVE_REVISIT)
} else {
protocol_interval.saturating_add(self.unsupported_backoff)
};
self.next_allowed.insert(event.remote_addr, now + delay);
#[cfg(feature = "metrics")]
{
counter!("dht_sample_infohashes_responses_total").increment(1);
counter!("dht_sample_infohashes_hashes_total").increment(discovered as u64);
gauge!("dht_sample_infohashes_in_flight").set(self.pending.len() as f64);
}
}
fn remember_hash(&mut self, hash: [u8; 20]) {
if self.dedup_capacity == 0 {
return;
}
if self.seen_hashes.insert(hash) {
self.seen_order.push_back(hash);
}
while self.seen_order.len() > self.dedup_capacity {
if let Some(expired) = self.seen_order.pop_front() {
self.seen_hashes.remove(&expired);
}
}
}
fn expire(&mut self, now: Instant) {
while let Some((deadline, key)) = self.pending_expiry.front().copied() {
if deadline > now {
break;
}
self.pending_expiry.pop_front();
if !self
.pending
.get(&key)
.is_some_and(|pending| pending.deadline == deadline)
{
continue;
}
self.pending.remove(&key);
self.pending_addrs.remove(&key.addr);
self.next_allowed
.insert(key.addr, now + self.unsupported_backoff);
self.runtime_stats.sample_timeout();
#[cfg(feature = "metrics")]
counter!("dht_sample_infohashes_timeouts_total").increment(1);
}
#[cfg(feature = "metrics")]
gauge!("dht_sample_infohashes_in_flight").set(self.pending.len() as f64);
}
fn next_transaction_id(&mut self) -> TransactionId {
let mut tid = self.next_tid.to_be_bytes();
tid[0] = SAMPLE_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 sample_transaction_ids_have_a_reserved_tag() {
assert!(is_sample_infohashes_tid(&[
SAMPLE_TID_TAG,
1,
2,
3,
4,
5,
6,
7
]));
assert!(!is_sample_infohashes_tid(&[0; 8]));
}
#[test]
fn bep51_response_fields_decode() {
let bytes = b"d1:rd2:id20:aaaaaaaaaaaaaaaaaaaa8:intervali60e3:numi2e7:samples40:bbbbbbbbbbbbbbbbbbbbcccccccccccccccccccce1:t8:123456781:y1:re";
let message: DhtMessage = serde_bencode::from_bytes(bytes).unwrap();
let response = message.r.unwrap();
assert_eq!(response.interval, Some(60));
assert_eq!(response.num, Some(2));
assert_eq!(response.samples.unwrap().len(), 40);
}
}
+7 -9
View File
@@ -1,4 +1,5 @@
use crate::metadata::{FetchedMetadata, MetadataFetchOutcome, RbitFetcher};
use crate::peer_lookup::PeerLookupRequest;
#[cfg(test)]
use crate::runtime_stats::DhtRuntimeLimits;
use crate::runtime_stats::DhtRuntimeStats;
@@ -60,7 +61,7 @@ pub struct MetadataSchedulerCallbacks {
pub(crate) struct MetadataSchedulerRuntime {
pub(crate) stats: DhtRuntimeStats,
pub(crate) peer_lookup_tx: Option<mpsc::Sender<[u8; 20]>>,
pub(crate) peer_lookup_tx: Option<mpsc::Sender<PeerLookupRequest>>,
}
#[derive(Debug, Clone, Copy)]
@@ -149,7 +150,7 @@ impl PendingHashQueue {
});
entry
.peers
.sort_unstable_by(|left, right| right.discovered_at.cmp(&left.discovered_at));
.sort_unstable_by_key(|peer| std::cmp::Reverse(peer.discovered_at));
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);
@@ -212,10 +213,7 @@ impl PendingHashQueue {
fn expire(&mut self, now: Instant) -> usize {
let mut expired = 0;
loop {
let Some((&oldest_key, oldest_hash)) = self.order.first_key_value() else {
break;
};
while let Some((&oldest_key, oldest_hash)) = self.order.first_key_value() {
if now.checked_duration_since(oldest_key.0).unwrap_or_default() <= self.ttl {
break;
}
@@ -269,7 +267,7 @@ pub struct MetadataScheduler {
total_completed: Arc<AtomicU64>,
queue_len: Arc<AtomicUsize>,
runtime_stats: DhtRuntimeStats,
peer_lookup_tx: Option<mpsc::Sender<[u8; 20]>>,
peer_lookup_tx: Option<mpsc::Sender<PeerLookupRequest>>,
shutdown: CancellationToken,
}
@@ -630,7 +628,7 @@ impl MetadataScheduler {
callback: &Arc<ArcSwapOption<TorrentAckCallback>>,
on_metadata_fetch: &Arc<ArcSwapOption<MetadataFetchCallback>>,
runtime_stats: &DhtRuntimeStats,
peer_lookup_tx: Option<mpsc::Sender<[u8; 20]>>,
peer_lookup_tx: Option<mpsc::Sender<PeerLookupRequest>>,
) -> JobResult {
if let Some(gate) = on_metadata_fetch.load_full()
&& !gate(job.info_hash.clone()).await
@@ -663,7 +661,7 @@ impl MetadataScheduler {
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 _ = peer_lookup_tx.try_send(PeerLookupRequest::new(info_hash_bytes));
})
});
let fetched = race_peer_fetches(
+23 -1
View File
@@ -11,6 +11,10 @@ use crate::peer_lookup::{
};
use crate::protocol::{DhtArgs, DhtMessage};
use crate::runtime_stats::{DhtRuntimeLimits, DhtRuntimeStats};
use crate::sample_infohashes::{
SampleInfohashesHandle, SampleInfohashesRuntime, is_sample_infohashes_tid,
spawn_sample_infohashes,
};
use crate::scheduler::{
MetadataCompletionCallback, MetadataFetchCallback, MetadataScheduler,
MetadataSchedulerCallbacks, MetadataSchedulerLimits, MetadataSchedulerRuntime,
@@ -257,6 +261,7 @@ pub struct DHTServer {
error_callback: Arc<ArcSwapOption<ErrorCallback>>,
crawl_engine: Arc<CrawlEngine>,
peer_lookup: PeerLookupHandle,
sample_infohashes: SampleInfohashesHandle,
hash_events_tx: mpsc::Sender<HashDiscovered>,
metadata_queue_len: Arc<AtomicUsize>,
max_metadata_queue_size: usize,
@@ -373,6 +378,19 @@ impl DHTServer {
shutdown: shutdown.clone(),
},
);
let sample_infohashes = spawn_sample_infohashes(
options.netmode,
node_id,
&sockets_by_bind_addr,
crawl_engine.snapshot.clone(),
crawl_engine.clone(),
peer_lookup.clone(),
SampleInfohashesRuntime {
options: options.sample_infohashes.clone(),
stats: runtime_stats.clone(),
shutdown: shutdown.clone(),
},
);
let scheduler = MetadataScheduler::new_with_runtime(
hash_rx,
@@ -409,6 +427,7 @@ impl DHTServer {
error_callback: Arc::new(ArcSwapOption::empty()),
crawl_engine,
peer_lookup,
sample_infohashes,
hash_events_tx,
metadata_queue_len,
max_metadata_queue_size,
@@ -675,7 +694,10 @@ impl DHTServer {
if let Some(response) = msg.r
&& let Some(tid) = transaction_id_from_bytes(&msg.t)
{
if is_peer_lookup_tid(&tid) {
if is_sample_infohashes_tid(&tid) {
self.sample_infohashes
.route_response(remote_addr, tid, response);
} else 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);
+39 -6
View File
@@ -119,6 +119,8 @@ pub struct DHTOptions {
pub metadata: MetadataOptions,
/// Active get_peers lookup rate and concurrency limits.
pub peer_lookup: PeerLookupOptions,
/// Active BEP-51 InfoHash sampling limits.
pub sample_infohashes: SampleInfohashesOptions,
/// Active crawl, node-pool and scheduler limits.
pub crawl: CrawlOptions,
}
@@ -149,6 +151,23 @@ pub struct PeerLookupOptions {
pub max_active_lookups: usize,
}
#[derive(Debug, Clone)]
/// Active BEP-51 `sample_infohashes` discovery limits.
pub struct SampleInfohashesOptions {
/// Maximum BEP-51 queries started per second. Zero disables sampling.
pub max_queries_per_second: u32,
/// Maximum sampling budget consumed immediately after an idle period.
pub burst: u32,
/// Maximum outstanding BEP-51 requests.
pub max_in_flight: usize,
/// Per-request timeout in milliseconds.
pub request_timeout_millis: u64,
/// Retry delay for timeouts or nodes that do not return samples, in seconds.
pub unsupported_backoff_secs: u64,
/// Maximum sampled InfoHashes retained for bounded in-memory deduplication.
pub dedup_capacity: usize,
}
#[derive(Debug, Clone, Default)]
/// Active crawl configuration grouped by responsibility.
pub struct CrawlOptions {
@@ -257,6 +276,7 @@ impl Default for DHTOptions {
hash_queue_capacity: 10_000,
metadata: MetadataOptions::default(),
peer_lookup: PeerLookupOptions::default(),
sample_infohashes: SampleInfohashesOptions::default(),
crawl: CrawlOptions::default(),
}
}
@@ -277,9 +297,22 @@ impl Default for MetadataOptions {
impl Default for PeerLookupOptions {
fn default() -> Self {
Self {
max_lookups_per_second: 32,
burst: 32,
max_active_lookups: 64,
max_lookups_per_second: 128,
burst: 128,
max_active_lookups: 256,
}
}
}
impl Default for SampleInfohashesOptions {
fn default() -> Self {
Self {
max_queries_per_second: 16,
burst: 16,
max_in_flight: 64,
request_timeout_millis: 1_500,
unsupported_backoff_secs: 300,
dedup_capacity: 1_000_000,
}
}
}
@@ -323,9 +356,9 @@ impl Default for BootstrapOptions {
"router.utorrent.com:6881".to_string(),
"dht.aelitis.com:6881".to_string(),
],
interval_secs: 300,
max_nodes_per_round: 3,
source_backoff_base_secs: 300,
interval_secs: 30,
max_nodes_per_round: 16,
source_backoff_base_secs: 60,
source_backoff_max_secs: 3_600,
}
}