feat: 重构 DHT 爬取与可观测性

This commit is contained in:
桥下红药
2026-07-19 15:26:04 +08:00
parent bcc166c53b
commit 7c79bff593
32 changed files with 6738 additions and 1598 deletions
+38
View File
@@ -0,0 +1,38 @@
# Changelog
本项目遵循语义化版本。0.2.0 是包含公开 API 变更的 breaking release。
## 0.2.0 - 2026-07-11
### Breaking changes
-`DHTOptions` 的 Metadata 和 crawl 参数改为嵌套结构:`MetadataOptions`
`CrawlOptions``RateLimitOptions``PoolOptions``BootstrapOptions`
`TargetOptions``SchedulerOptions`
- 删除旧的 `metadata_timeout``max_metadata_queue_size`
`max_metadata_worker_count``node_queue_capacity` 等扁平字段。
- 删除旧 active/candidate frontier 和 sharded queue 实现,改用单所有者严格 FIFO
节点池、recent-probe set 与 responsive-node ring。
- Metadata 调度改为有界、按 InfoHash 去重、最多三个 Peer、60 秒 freshness TTL。
### Added
- 独立的主动爬取 QPS、新目标、节点替换、回复包/字节、单来源回复、总在途和子网在途限制。
- 根据 Metadata 队列压力自动降低实际 `find_node` QPS。
- Bootstrap 来源退避、低水位触发和响应节点快照。
- `on_torrent_with_ack``on_metadata_fetch_complete`
`MetadataFetchCompletionStatus` 和真实 Peer `attempts`
-`SocketAddr` 缓存 Metadata Peer 的 timeout/connect failure。
- `DhtRuntimeStats::snapshot()``observability_snapshot()` 和三组固定桶直方图。
- DHT、UDP、节点池、Metadata scheduler/fetcher 的低基数 Prometheus 指标。
### Changed
- Metadata timeout 现在覆盖连接、握手、传输、SHA1 和解析的完整 Peer 尝试。
- UDP ingress、crawl events 和 Metadata queues 全部有界,并暴露 drop/depth 指标。
- DHT 回复增加总包、总字节、单来源限流,以及 `ping`/`get_peers` 10% 保底预算。
- JNI `DHTOptions` 更新为 0.2 配置的扁平化子集;未映射字段继续采用 Rust 默认值。
### Migration
迁移字段表、回调语义和运行时示例见 [README](README.md#从-01-迁移到-02)。
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dht-crawler"
version = "0.1.2"
version = "0.2.0"
edition = "2024"
authors = ["桥下红药 <1121744186@qq.com>"]
description = "高性能的 Rust DHT (Distributed Hash Table) 爬虫库 | A high-performance Rust DHT crawler library for fetching torrent information from the BitTorrent DHT network"
@@ -33,9 +33,12 @@ ahash = "0.8"
serde_bytes = "0.11.19"
metrics = { version = "0.24", optional = true }
async-channel = "2.5.0"
crossbeam-queue = "0.3"
jni = { version = "0.21", optional = true }
arc-swap = "1.7"
[dev-dependencies]
tokio = { version = "1.35", features = ["signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "tracing-log"] }
mimalloc = "0.1"
+306 -162
View File
@@ -2,238 +2,382 @@
[![Crates.io](https://img.shields.io/crates/v/dht-crawler.svg)](https://crates.io/crates/dht-crawler)
[![Documentation](https://docs.rs/dht-crawler/badge.svg)](https://docs.rs/dht-crawler)
[![License](https://img.shields.io/crates/l/dht-crawler.svg)](https://github.com/0xddy/dht-crawler/blob/master/LICENSE)
[![License](https://img.shields.io/crates/l/dht-crawler.svg)](LICENSE)
一个基于 RustTokio 实现的高性能分布式哈希表 (DHT) 爬虫库。它能够加入 BitTorrent DHT 网络,监听并自动获取种子的元数据(Metadata/InfoHash)。
基于 RustTokio 的 BitTorrent DHT 爬虫库。它加入 BEP-5 网络,接收有效的
`announce_peer`,并通过 BEP-9 `ut_metadata` 下载和校验种子元数据。
## ✨ 核心特性
当前版本:`0.2.0`。0.2 重做了节点池、主动爬取、Metadata 调度和运行时观测接口,
从 0.1 升级时请先阅读[迁移说明](#从-01-迁移到-02)和 [CHANGELOG](CHANGELOG.md)。
- **🚀 极致性能**:基于 `Tokio` 异步运行时构建,支持数万级的高并发连接处理。
- **📦 自动元数据抓取**:内置元数据获取引擎,自动完成从 InfoHash 到种子详情的抓取。
- **🌐 双栈网络支持**:完美支持 IPv4 和 IPv6DualStack 模式),扩大节点覆盖范围。
- **⚡ 高度可配置**:支持自定义并发数、队列大小、超时时间等核心参数。
- **📊 监控友好**:提供 Prometheus 指标导出接口,轻松监控爬虫状态(可选)。
## 文档导航
## 🏗️ 架构与流程
- [快速开始](#快速开始):最小可运行示例和优雅停机。
- [架构与背压](#架构与背压)UDP、主动爬取和 Metadata 管道。
- [配置默认值](#配置默认值):所有公开 `DHTOptions` 字段。
- [回调与生命周期](#回调与生命周期):抓取准入、交付确认和完成状态。
- [运行时观测](#运行时观测):无 exporter 快照、Prometheus 和完整[指标表](docs/metrics.md)。
- [JNI](#jni)Java 集成入口;[0.1 → 0.2 迁移](#从-01-迁移到-02)。
本库采用了 **Reactor 模式****Worker Pool** 相结合的高并发架构,确保了在处理海量 UDP 数据包时的吞吐量。
## 主要能力
### 系统架构图
- IPv4、IPv6 和双栈 DHT Socket。
- 单所有者 crawl actor:严格 FIFO 节点池、最近探测状态、在途请求和所有速率预算
由一个 actor 管理,UDP worker 不锁节点池。
- 查询 QPS、新目标/分钟、节点替换/分钟、总在途、子网在途、回复包、回复字节和
单来源回复分别限流。
- 有界 Metadata 队列按 InfoHash 去重,并保留最多三个新鲜 Peer。
- Metadata 总超时覆盖 TCP 连接、BitTorrent/扩展握手、分片下载、SHA1 校验和解析。
-`SocketAddr` 缓存 Peer 的超时/连接失败,避免坏 Peer 反复占用 worker。
- 传输无关的原子运行时快照和固定桶直方图;可选 `metrics` feature。
- 可选 JNI 接口和 Java 示例。
```mermaid
graph TD
%% 网络层
Network((DHT Network)) <-->|UDP Packets| Socket[UDP Socket]
%% 接收与分发
subgraph Receiver [Packet Receiver]
Socket -->|recv_from| Reader[UDP Reader / Dispatcher]
Reader -->|Round Robin| Ch1[Channel 1]
Reader -->|Round Robin| Ch2[Channel 2]
Reader -->|...| ChN[Channel N]
end
%% 并行处理
subgraph Processing [Packet Processing Workers]
Ch1 --> W1[Worker 1]
Ch2 --> W2[Worker 2]
ChN --> WN[Worker N]
W1 & W2 & WN -->|Parse & Logic| Logic{Protocol Logic}
end
%% 业务逻辑分支
Logic -->|Discover Node| NodeMgr[Node Queue]
Logic -->|Discover InfoHash| HashQ[Hash Queue]
Logic -->|On Error| ErrorCb[User on_error Callback]
%% 元数据抓取子系统
subgraph Metadata [Metadata Subsystem]
HashQ --> Scheduler[Scheduler]
Scheduler -->|Spawn| MetaW1[Meta Worker 1]
Scheduler -->|...| MetaWN[Meta Worker N]
MetaW1 & MetaWN <-->|TCP / ut_metadata| Peer((Remote Peer))
end
MetaW1 & MetaWN -->|Success| Callback[User Callback]
```
### 核心流程解析
1. **UDP 读取与分发 (Reader & Dispatcher)**:
* 独立的 UDP Reader 任务持续从 Socket 读取数据包。
* 使用 Round-Robin 策略将数据包分发给 N 个(默认为 CPU 核心数)处理 Channel,实现无锁的负载均衡。
2. **并行协议处理 (Packet Workers)**:
* N 个 Packet Worker 并行消费 Channel 中的数据。
* 负责 Bencode 解码、KRPC 协议解析、消息路由(Query/Response)。
* 高效处理 `get_peers``announce_peer` 消息,提取 InfoHash。
* 运行时错误通过 `on_error` 回调上报,不触发 panic,便于 JNI 等集成场景。
3. **元数据调度 (Metadata Subsystem)**:
* 提取出的 InfoHash 进入独立的 Hash Queue。
* Scheduler 根据配置的并发度(如 1000+)动态启动 Metadata Worker。
* Worker 通过 TCP 连接 Peer,使用 BEP-0009 协议下载种子元数据。
## 📦 安装
在你的 `Cargo.toml` 中添加依赖:
## 安装
```toml
[dependencies]
dht-crawler = "0.1"
dht-crawler = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
```
如果需要 **Prometheus 监控支持**
如果应用需要通过 `metrics` facade 输出指标
```toml
[dependencies]
dht-crawler = { version = "0.1", features = ["metrics"] }
dht-crawler = { version = "0.2", features = ["metrics"] }
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
```
## 🚀 快速开始
`metrics` feature 只负责记录指标,不会在库内启动 HTTP 服务。应用必须自行安装
recorder/exporter;完整指标清单见 [docs/metrics.md](docs/metrics.md)。
下面是一个最简的启动示例。它会启动一个 DHT 节点,并在抓取到新种子时打印日志。
### Cargo features
| Feature | 默认启用 | 作用 |
|---|---|---|
| `metrics` | 否 | 通过 `metrics` facade 记录低基数指标 |
| `jni` | 否 | 构建 Java JNI 接口和 `cdylib` |
| `mimalloc` | 否 | 将 mimalloc 注册为全局分配器 |
库的默认 feature 集为空。启用 `mimalloc` 前,请确认最终二进制没有注册其他全局分配器。
## 快速开始
```rust
use dht_crawler::prelude::*;
#[tokio::main]
async fn main() -> Result<()> {
// 1. 配置爬虫参数
let options = DHTOptions {
port: 12313,
netmode: NetMode::Ipv4Only,
metadata: MetadataOptions {
timeout_secs: 4,
max_queue_size: 10_000,
max_worker_count: 256,
..Default::default()
},
crawl: CrawlOptions {
rate_limit: RateLimitOptions {
max_find_node_rate_per_sec: 200,
burst: 40,
max_in_flight: 512,
..Default::default()
},
..Default::default()
},
..Default::default()
};
// 2. 初始化 Server
let server = DHTServer::new(options).await?;
println!("DHT Server 启动于端口 12313...");
// 3. 注册错误回调:运行时错误通过回调输出,避免 panic(适合 JNI/嵌入式场景)
server.on_error(|err| {
eprintln!("DHT 错误: {}", err);
// 返回 true 才允许该 InfoHash 进入实际 Peer 下载阶段。
server.on_metadata_fetch(|_info_hash| async move { true });
// 简单回调总是接受交付。
server.on_torrent(|torrent| {
println!("{}: {}", torrent.info_hash, torrent.name);
});
// 4. 注册回调:成功获取到种子元数据时触发
server.on_torrent(move |torrent| {
println!("🎉 抓取成功: {} (文件数: {})", torrent.name, torrent.files.len());
server.on_error(|error| eprintln!("DHT runtime error: {error}"));
let shutdown_server = server.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
shutdown_server.shutdown();
}
});
// 5. 可选:在拉取元数据前过滤 info_hash,返回 true 表示允许拉取
server.on_metadata_fetch(|_hash| async move { true });
// 6. 启动服务
server.start().await?;
Ok(())
// start() 阻塞到 shutdown() 被调用。
server.start().await
}
```
*完整的可运行代码请参考 [examples/main.rs](examples/main.rs)*
可运行版本见 [examples/main.rs](examples/main.rs)。如果自己的 Tokio 依赖没有启用
`signal` feature,可以使用其他取消源调用 `shutdown()`
## ⚙️ 配置详解
## 架构与背压
`DHTOptions` 提供了丰富的配置项来调整爬虫行为:
```text
UDP sockets
├─ bounded UDP worker queues ──→ KRPC workers ──→ bounded crawl events
│ │
│ └─ announce_peer → bounded hash ingress
└─ crawl egress ← single crawl actor ← priority/discovery events
├─ strict FIFO node pool + recent-probe set
├─ pending transaction map + subnet counters
└─ ArcSwap responsive-node snapshot
```rust
let options = DHTOptions {
// 监听端口
port: 12313,
// 网络模式:Ipv4Only, Ipv6Only, 或 DualStack (默认)
netmode: NetMode::Ipv4Only,
// 元数据获取超时时间 (秒)
metadata_timeout: 5,
// 元数据下载队列大小,建议根据内存大小调整
max_metadata_queue_size: 100000,
// 同时进行元数据下载的并发任务数
max_metadata_worker_count: 1000,
// 节点池容量(DHT 节点队列)
node_queue_capacity: 100000,
// InfoHash 发现队列容量
hash_queue_capacity: 10000,
..Default::default()
};
hash ingress → deduplicating Metadata queue → bounded workers → torrent callback
```
**可选 API**`server.set_filter(|info_hash_hex| bool)` 可在发现阶段过滤要处理的 info_hash(返回 `true` 表示允许进入元数据队列)。
所有跨任务入口都是有界队列。达到容量时,事件会被拒绝、淘汰或计入 drop 指标,
不会依靠无限增长的缓冲区掩盖下游过载。主动爬取预算还会随 Metadata 队列压力下降。
## 错误处理
### 主动爬取
库内采用严格的错误处理策略,避免底层异常导致进程崩溃,便于集成 JNI 或嵌入式场景
- 新地址通常只发送一次 `find_node`;默认等待回复 `2s`,不做同目标重试
- 超时不会自动降低配置 QPS。Metadata 队列压力达到 80% 后才开始自动降速,95% 时
降到 `metadata_pressure_floor_percent` 指定的比例;默认下限为配置 QPS 的 25%。
- 节点池是严格 FIFO。重复地址、无效公网地址和超出 replacement budget 的替换会被拒绝。
- 响应成功的节点进入一个独立、有界、带 TTL 的 responsive ring,用于回复其他 DHT
节点和 revisit 查询;它不是第二个爬取池。
- 节点池低于 `low_watermark` 时触发 bootstrap。失败的 bootstrap 来源按配置退避。
- UDP 回复总包数、总字节数和单来源包数分别限流,其中 10% 包/字节预算保留给
`ping``get_peers` 的保底回复,但不会突破配置的总上限。
### 错误类型 `DHTError`
### Metadata 调度
```rust
use dht_crawler::{DHTError, Result};
- Hash ingress 和 Metadata pending queue 都是有界的。
- Pending queue 按 InfoHash 去重,每个 Hash 最多保留三个不同且新鲜的 Peer。
- Pending 项固定在 60 秒后过期。队列满时,较新的 Hash 可以淘汰最旧项;比当前
最旧项还旧的事件直接视为 stale。
- worker 优先分派最新的可用 Hash,以提高 Peer 仍在线的概率。
- `timeout_secs` 是一次 Peer 尝试的端到端期限,不会在连接、握手和下载阶段重复叠加。
- 单个 metadata payload 上限为 10 MiB;下载完成后必须通过 SHA1 和 bencode 解析。
- Peer failure cache 只缓存 `timeout``connect_failed`,键为完整 `SocketAddr`
(IP + port)。缓存命中不会发起网络请求,也不计入三次真实 Peer 尝试。
- `peer_failure_cache_capacity = 0``peer_failure_ttl_secs = 0` 会关闭缓存。
// 错误变体包括:
// - DHTError::Network(io::Error) — 网络/IO 错误
// - DHTError::Init(String) — 初始化错误(如 socket、worker
// - DHTError::Internal(String) — 内部逻辑错误
// - DHTError::LockPoisoned(String) — 锁中毒(预留)
// - DHTError::Other(String) — 其他
## 配置默认值
库本身不包含 P1/P15 等档位概念。应用如需档位,应将其转换成下列具体选项。
### `DHTOptions` 与 Metadata
| 字段 | 默认值 | 说明 |
|---|---:|---|
| `port` | `6881` | DHT UDP 监听端口 |
| `netmode` | `Ipv4Only` | `DHTOptions::default()` 的网络模式 |
| `hash_queue_capacity` | `10000` | announce 到 Metadata scheduler 的 ingress 容量 |
| `metadata.timeout_secs` | `4` | 单 Peer 端到端超时 |
| `metadata.max_queue_size` | `10000` | 去重 Pending Hash 容量 |
| `metadata.max_worker_count` | `256` | 最大并发 Metadata job 数 |
| `metadata.peer_failure_cache_capacity` | `200000` | 坏 Peer 缓存容量 |
| `metadata.peer_failure_ttl_secs` | `60` | 坏 Peer 缓存 TTL |
### `crawl.rate_limit`
| 字段 | 默认值 |
|---|---:|
| `max_find_node_rate_per_sec` | `200` |
| `burst` | `40` |
| `max_in_flight` | `512` |
| `request_timeout_secs` | `2` |
| `max_new_destinations_per_minute` | `10000` |
| `max_replacements_per_minute` | `25000` |
| `max_response_rate_per_sec` | `500` |
| `max_response_bytes_per_sec` | `1048576` |
| `max_response_rate_per_source` | `40` |
| `metadata_pressure_floor_percent` | `25` |
| `max_in_flight_per_subnet` | `8` |
### Pool、Bootstrap、Target 与 Scheduler
| 字段 | 默认值 |
|---|---:|
| `pool.capacity` | `100000` |
| `pool.recent_probe_ttl_secs` | `600` |
| `pool.responsive_capacity` | `16384` |
| `pool.responsive_ttl_secs` | `900` |
| `pool.low_watermark` | `10000` |
| `bootstrap.interval_secs` | `300` |
| `bootstrap.max_nodes_per_round` | `3` |
| `bootstrap.source_backoff_base_secs` | `300` |
| `bootstrap.source_backoff_max_secs` | `3600` |
| `target.random_walk_percent` | `70` |
| `target.sparse_bucket_percent` | `30` |
| `target.neighbor_sender_id` | `true` |
| `scheduler.priority_event_channel_capacity` | `8192` |
| `scheduler.discovery_event_channel_capacity` | `16384` |
| `scheduler.event_batch_limit` | `256` |
| `scheduler.node_batch_limit` | `4096` |
| `scheduler.routing_snapshot_size` | `4096` |
| `scheduler.snapshot_refresh_millis` | `1000` |
默认 bootstrap 来源:
```text
router.bittorrent.com:6881
dht.transmissionbt.com:6881
router.utorrent.com:6881
dht.aelitis.com:6881
```
### 注册错误回调 `on_error`
内部会对不安全的零值和百分比做归一化,例如容量/在途至少为 1、百分比最大为 100、
`low_watermark` 不超过 pool capacity。建议调用方仍显式传入有效配置,不依赖归一化。
运行时错误(如协议处理失败)会通过回调上报,而不会 panic:
## 回调与生命周期
### `on_metadata_fetch`
在 Hash 首次准备进入 Peer 下载前调用。返回 `false` 表示 gate reject:不下载、不触发
`on_torrent`,也不会触发 `on_metadata_fetch_complete`
### `on_torrent` 与 `on_torrent_with_ack`
- `on_torrent` 适合无需确认交付的调用方;回调返回后视为 `Accepted`
- `on_torrent_with_ack` 返回 `true` 表示应用接受交付,返回 `false` 表示
`DeliveryRejected`。后者代表 Metadata 已成功下载,但业务层没有接收,不等同于
`FetchFailed`
- 如果没有注册任何 torrent callback,成功下载的 Metadata 同样按 `DeliveryRejected`
结束。
- Torrent 回调发生 panic 时会被捕获并按拒绝交付处理。
### `on_metadata_fetch_complete`
一个通过 gate 的 Hash 最终只发出一次完成通知:
| 状态 | 含义 |
|---|---|
| `Accepted` | 下载成功,torrent callback 接受交付 |
| `FetchFailed` | 所有可用 Peer 尝试失败 |
| `DeliveryRejected` | 下载成功,torrent callback 拒绝交付 |
`attempts` 只统计实际发起的 Peer 网络尝试;failure cache 命中不计入。
### 启动与停止
- `DHTServer::new()` 创建并绑定所需 Socket,失败直接返回 `Err`
- `start()` 启动后台任务并等待取消,因此通常应在应用主任务中 await。
- `shutdown()` 可从 clone handle 调用,取消 DHT、crawl 和 Metadata 后台任务。
- 已 shutdown 的同一实例不能重新 start;需要重新构造 `DHTServer`
- `on_error` 用于接收运行期协议/worker 错误;初始化错误仍通过 `Result` 返回。
## 运行时观测
不启用 `metrics` feature 也可以读取运行时快照:
```rust
let server = DHTServer::new(options).await?;
let stats = server.runtime_stats();
// 将错误输出到 stderr 或接入自己的日志/监控
server.on_error(|err| {
log::error!("DHT 运行时错误: {}", err);
});
let runtime = stats.snapshot();
println!(
"nodes={} metadata={}/{} in_flight={}",
runtime.node_pool_size,
runtime.metadata_queue_depth,
runtime.metadata_queue_max,
runtime.metadata_in_flight,
);
// JNI 场景示例:将错误传回 Java 层
// server.on_error(|err| {
// jni_callback_on_error(env, err.to_string());
// });
let observability = stats.observability_snapshot();
println!(
"udp rx={}B tx={}B fetch_p95={:?}ms",
observability.udp_rx_bytes,
observability.udp_tx_bytes,
observability.fetch_duration_ms.percentile(0.95),
);
```
### 同步错误:`Result` 传播
快照使用 relaxed atomic load,适合监控,不是跨字段事务视图。计数器是进程生命周期
累计值,调用方通过相邻快照差值计算 rate,并应处理进程重启导致的 counter reset。
`DHTServer::new()``server.start()` 返回 `Result`,调用方需处理或传播
固定桶
| 直方图 | 边界/单位 |
|---|---|
| Metadata queue wait | `10/50/100/250/500/1000/2000/5000 ms` |
| Metadata fetch | `250/500/1000/2000/4000/6000/10000 ms` |
| Metadata payload size | `16/32/64/128/256/512/1024 KiB, 10 MiB` |
`counts[i]` 表示小于等于 `bounds[i]` 的非累计桶计数,`overflow` 表示超过最后边界的
数量。`percentile()` 返回桶上界;落入 overflow 时只能返回最后一个上界,因此它是
有界近似值,不是精确分位数。
高级快照类型从 crate 根导出;当前 prelude 只重导出 `DhtRuntimeStats`
`DhtRuntimeSnapshot`
## Prometheus / metrics
启用 `metrics` 后,库通过 `metrics` facade 记录 counter、gauge 和 histogram。
应用必须在创建/启动 server 前安装全局 recorder。示例:
```rust
#[tokio::main]
async fn main() -> Result<()> {
let server = DHTServer::new(options).await?; // 初始化失败会返回 Err
server.on_error(|e| eprintln!("{}", e));
server.start().await?; // 启动失败(如 socket 绑定)会返回 Err
Ok(())
}
use metrics_exporter_prometheus::PrometheusBuilder;
PrometheusBuilder::new()
.with_http_listener("127.0.0.1:9000".parse().unwrap())
.install()
.unwrap();
```
## 编译示例
完整名称、标签和单位见 [docs/metrics.md](docs/metrics.md)。
### 1. 启用 `mimalloc`(内存优化)
## 应用层集成
在长运行的高并发场景下,使用 `mimalloc` 可降低约 10–30% 内存占用。本库的 `mimalloc` feature 仅用于方便编译/运行示例;若将本库作为依赖使用,请在自己的 bin 项目中单独引入并配置 mimalloc 全局分配器。
本 crate 只提供 DHT、BEP-9 Metadata、回调和观测能力,不包含 Redis、Manticore、
HTTP 看板或 `P1``P16` 性能档位。同级的 `dht-crawler-node` 项目负责这些应用层策略,
并把档位转换成具体的 `DHTOptions`。开发两个项目时应保持下面的目录关系:
```text
workspace-parent/
├── dht-crawler/
└── dht-crawler-node/
```
## JNI
启用 `jni` feature 可构建 `cdylib`。Java 示例、线程模型和 JNI 配置字段见
[examples-jni/README.md](examples-jni/README.md)。Java `DHTOptions` 是 Rust 配置的
扁平化子集,未暴露的 Bootstrap、Target、Scheduler 和 Peer failure cache 字段使用
Rust 默认值。
## 从 0.1 迁移到 0.2
0.2 是 breaking release
| 0.1 | 0.2 |
|---|---|
| `metadata_timeout` | `metadata.timeout_secs` |
| `max_metadata_queue_size` | `metadata.max_queue_size` |
| `max_metadata_worker_count` | `metadata.max_worker_count` |
| `node_queue_capacity` | `crawl.pool.capacity` |
| 旧 active/candidate frontier | 单所有者严格 FIFO pool + responsive ring |
| 无交付确认 | `on_torrent_with_ack` + `DeliveryRejected` |
| 粗粒度统计 | `runtime_stats()` 的两类原子快照和固定桶 |
`DHTOptions` 不提供旧字段兼容层,升级时必须修改构造代码。配置档位属于应用策略,
不在库内实现。
## 构建与验证
```bash
cargo run --release --example dht_crawler_example --features mimalloc
cargo fmt --all --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo doc --no-deps --all-features
cargo run --release --example dht_crawler_example
```
### 2. 启用 `metrics`(监控)
启用后,可通过 HTTP 接口拉取 Prometheus 格式的监控数据。
JNI
```bash
cargo run --release --example dht_crawler_example --features metrics
cargo build --release --features jni
```
监控地址:http://localhost:9000/metrics
## 许可证
## 📜 许可证
MIT License
[MIT](LICENSE)
+99
View File
@@ -0,0 +1,99 @@
# dht-crawler 指标参考
启用 Cargo feature `metrics` 后,库通过 `metrics` facade 记录以下指标。库不安装
recorder、不监听端口,也不依赖任何特定导出协议;Prometheus exporter 应由最终应用安装。
所有 `*_total` 都是进程生命周期累计 counter。Gauge 是当前值。Histogram 的记录值
使用下表标出的单位。
## UDP 与 KRPC
| 指标 | 类型 | 标签 | 单位/含义 |
|---|---|---|---|
| `dht_udp_bytes_received_total` | counter | — | Socket 接收字节 |
| `dht_udp_packets_received_total` | counter | `status=ok|dropped_size|dropped_magic|queue_full` | UDP ingress 结果 |
| `dht_udp_bytes_sent_total` | counter | — | 成功发送字节 |
| `dht_udp_packets_sent_total` | counter | `type=query|response` | 成功发送包 |
| `dht_udp_query_size_bytes` | histogram | — | find_node query 编码长度,bytes |
| `dht_messages_processed_total` | counter | `type=q|r|e|unknown` | 成功解析的 KRPC 消息类型 |
| `dht_messages_parse_error_total` | counter | — | bencode/KRPC 解析失败 |
| `dht_queries_total` | counter | `q=ping|find_node|get_peers|announce_peer|vote|other_or_invalid` | 入站查询类型 |
| `dht_udp_responses_dropped_total` | counter | `reason=rate_limit` | 最终未发送的限流回复 |
| `dht_udp_responses_priority_reserved_total` | counter | `query=ping|get_peers` | 使用 10% 保底预算的回复 |
`dht_udp_bytes_received_total` 包含后续被判定为 invalid/queue-full 的 Datagram;发送侧只在
`send_to` 成功后累计。
## 主动爬取与节点池
| 指标 | 类型 | 标签 | 含义 |
|---|---|---|---|
| `dht_node_pool_size` | gauge | — | 当前 FIFO 节点数 |
| `dht_node_pool_oldest_age_seconds` | gauge | — | FIFO 最老节点年龄 |
| `dht_node_pool_admissions_total` | counter | — | 新准入节点 |
| `dht_node_pool_replacements_total` | counter | — | 满池替换 |
| `dht_node_pool_dropped_total` | counter | `reason=duplicate|rate_limit|invalid` | 节点拒绝原因 |
| `dht_find_node_in_flight` | gauge | — | 当前在途 find_node |
| `dht_find_node_effective_rate_per_second` | gauge | — | Metadata 压力调整后的实际预算 |
| `dht_crawl_queries_sent_total` | counter | `kind=new|revisit|bootstrap` | 已交给 egress 的查询用途;发送失败另计 |
| `dht_find_node_responses_total` | counter | — | 与 pending transaction 匹配的回复 |
| `dht_find_node_response_unmatched_total` | counter | — | 无匹配 pending 的回复 |
| `dht_find_node_timeouts_total` | counter | — | pending 超时 |
| `dht_find_node_send_failures_total` | counter | — | UDP query 发送失败 |
| `dht_crawl_events_dropped_total` | counter | `kind=discovered|response` | 有界 actor channel 丢弃 |
| `dht_metadata_queue_pressure_ratio` | gauge | — | Metadata depth/capacity,范围 0..1 |
actor 每秒把内部增量 flush 到 counter,因此 exporter 看到的 counter 可能最多延迟约一秒。
## announce 与 Metadata ingress
| 指标 | 类型 | 标签 | 含义 |
|---|---|---|---|
| `dht_announce_peer_blocked_total` | counter | `reason=invalid_token|filtered` | announce 拒绝原因 |
| `dht_info_hashes_discovered_total` | counter | — | token/hash/filter 校验通过的 InfoHash |
| `dht_metadata_ingress_dropped_total` | counter | `reason=queue_full` | Hash ingress 满导致的丢弃 |
## Metadata scheduler
| 指标 | 类型 | 标签 | 单位/含义 |
|---|---|---|---|
| `dht_metadata_queue_depth` | gauge | — | Pending Hash 数 |
| `dht_metadata_in_flight` | gauge | — | 当前 job 数 |
| `dht_metadata_queue_events_total` | counter | `result=inserted|deduplicated|evicted_oldest|stale|expired` | 队列事件 |
| `dht_metadata_queue_wait_seconds` | histogram | — | Hash 从最近发现到首次分派的秒数 |
| `dht_metadata_jobs_dispatched_total` | counter | — | 分派 job 数 |
| `dht_metadata_jobs_completed_total` | counter | `result=accepted|fetch_failed|delivery_rejected|gate_rejected` | job 终态 |
| `dht_metadata_worker_join_error_total` | counter | — | worker task join 失败 |
| `dht_metadata_completion_callback_panics_total` | counter | — | 完成回调 panic |
## Metadata Peer 下载
| 指标 | 类型 | 标签 | 单位/含义 |
|---|---|---|---|
| `dht_metadata_fetch_attempts_total` | counter | — | 实际 Peer 尝试 |
| `dht_metadata_peer_attempts_total` | counter | — | 与 fetch attempts 相同的 Peer 尝试计数 |
| `dht_metadata_fetch_success_total` | counter | — | 成功下载和解析 |
| `dht_metadata_fetch_result_total` | counter | `result=success|failed|timeout` | Peer 尝试结果 |
| `dht_metadata_fetch_fail_total` | counter | `reason=timeout|send_error|size_limit|sha1_mismatch|parse_error` | 详细失败原因 |
| `dht_metadata_connection_result_total` | counter | `result=success|failed` | TCP/BitTorrent connect 结果 |
| `dht_metadata_handshake_result_total` | counter | `result=success|no_extension_support` | 扩展能力/最终校验结果 |
| `dht_metadata_fetch_duration_seconds` | histogram | — | 端到端 Peer 尝试秒数 |
| `dht_metadata_size_bytes` | histogram | — | 完整 bencoded info payload 字节数 |
| `dht_metadata_bytes_downloaded_total` | counter | — | 收到的 Metadata piece 数据字节数 |
| `dht_metadata_peer_failure_cache_hits_total` | counter | `reason=timeout|connect_failed` | 坏 Peer 缓存命中 |
| `dht_metadata_peer_failure_cache_inserts_total` | counter | `reason=timeout|connect_failed` | 坏 Peer 缓存写入 |
| `dht_metadata_peer_failure_cache_entries` | gauge | — | 当前缓存条目数 |
`dht_metadata_fetch_result_total{result="failed"}` 汇总非 timeout 的失败,不适合单独用于
分析具体原因;详细原因应结合 `fetch_fail`、connection 和 handshake 指标。
## 与原子快照的关系
`DhtRuntimeStats` 始终可用,与 `metrics` feature 无关:
- `snapshot()` 提供队列、节点池、crawl、Peer 和 UDP 运行状态。
- `observability_snapshot()` 提供 UDP 字节/包、入站查询分类、announce、节点准入、
Metadata 失败分类、failure cache 分类和固定桶。
两套出口在同一事件点更新,但读取时都不是跨字段事务快照;短时间内可能相差一个并发
事件,Prometheus 的 crawl actor counter 还可能有最多约一秒 flush 延迟。
+82 -45
View File
@@ -1,6 +1,6 @@
# dht-crawler-jni Java 示例
# dht-crawler JNI Java 示例
本目录是一个 Gradle 管理的 Java 项目,演示如何通过 JNI 使`dht-crawler` Rust 库
Gradle 项目,演示通过 JNI `dht-crawler`(需启用 Cargo feature `jni`
## 项目结构
@@ -9,79 +9,116 @@ examples-jni/
├── build.gradle
├── settings.gradle
└── src/main/java/cn/lmcw/dht/
├── model/ # DHTOptions, TorrentInfo, FileInfo
├── DhtCrawler.java # 面向对象入口(推荐)
├── DhtCrawlerJni.java # 包内 native 绑定
├── DhtListener.java
├── model/ # DHTOptions, TorrentInfo, FileInfo
├── DhtCrawler.java # 面向对象入口(推荐)
├── DhtCrawlerJni.java # native 方法声明
├── DhtListener.java # 回调接口
└── DhtCrawlerExample.java
```
## 快速开始
Rust JNI 实现位于 **`dht-crawler/jni/`**`lib` crate-type 含 `cdylib`)。
### 方式一:直接下载 Release 中的 JAR 运行(推荐)
## 编译 native 库
从 GitHub Release 页面下载两个文件
1. `dht-crawler-jni-example-<version>.jar` — 平台无关的 fat JAR
2. 对应平台的 JNI 动态库 zip(如 `dht_crawler_jni-<version>-x86_64-unknown-linux-gnu.zip`),解压得到 `libdht_crawler.so` / `dht_crawler.dll` / `libdht_crawler.dylib`
将 JAR 和动态库放到同一目录,然后执行:
```bash
# Linux / macOS
java -Djava.library.path=. -jar dht-crawler-jni-example-<version>.jar
# WindowsPowerShell/CMD 需对 -D 参数加引号,否则会报找不到主类)
java "-Djava.library.path=." -jar dht-crawler-jni-example-<version>.jar
```
### 方式二:从源码编译并运行
#### 1. 编译 Rust JNI 动态库
在仓库**根目录**执行:
**`dht-crawler`** 目录(本 README 的上一级)执行
```bash
cargo build --release --features jni
```
产物路径`target/release/`
产物路径(因平台而异):
#### 2. 运行 Java 示例
- Linux: `target/release/libdht_crawler.so`
- Windows: `target/release/dht_crawler.dll`
- macOS: `target/release/libdht_crawler.dylib`
在本目录(`examples-jni/`)执行:
## 运行示例
### 方式一:Release 预编译包(推荐)
下载 Release 中的 fat JAR 与对应平台 native 库 zip,放在同一目录:
```bash
gradle run
gradle run -Plib.path=/path/to/your/lib
# Linux / macOS
java -Djava.library.path=. -jar dht-crawler-jni-example-<version>.jar
# Windows
java "-Djava.library.path=." -jar dht-crawler-jni-example-<version>.jar
```
#### 3. 构建 fat JAR
### 方式二:源码 + Gradle
```bash
cd examples-jni
gradle run
# 指定 native 库目录(默认为 ../target/release
gradle run -Plib.path=/path/to/lib
```
构建 fat JAR
```bash
gradle shadowJar
```
## 在自己的项目中集成
## 集成到自己的 Java 项目
1. 复制 `cn/lmcw/dht/` 下源码(含 `model/``DhtCrawler``DhtCrawlerJni``DhtListener`)。
2. 将对应平台的 so/dll/dylib 放`java.library.path`
1. 复制 `cn/lmcw/dht/` (含 `model/``DhtCrawler``DhtCrawlerJni``DhtListener`)。
2. 将对应平台的 native 库加`java.library.path`
3. 使用与示例相同的 `dht-crawler` JNI 版本构建 `cdylib`
## API(面向对象)
```java
DhtCrawler crawler = DhtCrawler.createServer(options, listener);
crawler.start();
crawler.start(); // 后台启动,不阻塞调用线程
// ...
crawler.stop(); // 或 try-with-resources
crawler.stop(); // 或 try-with-resources
```
- **`DhtCrawler.createServer(options, listener)`**:创建会话(未启动 DHT;Java 不能用方法名 `new`,故不用 `open`)。
- **`start()`**:后台启动 DHT,非阻塞;同一会话多次 `start()` 仅首次生效。
- **`stop()` / `close()`**:停止并释放 Rust 资源,幂等。
- **`getNodePoolSize()`**routing table 节点数。
| 方法 | 说明 |
|------|------|
| `createServer(options, listener)` | 创建 `ServerHandle`(含独立 tokio `Runtime` + `DHTServer` |
| `start()` | 在 runtime 内 spawn `server.start()`;同一会话多次调用仅首次生效 |
| `stop()` / `close()` | `shutdown()` 后在后台线程 drop Runtime,避免阻塞 JNI 线程 |
| `getNodePoolSize()` | 节点池大小(`DHTServer::get_node_pool_size` |
Native 导出类:`cn.lmcw.dht.DhtCrawlerJni``createServer` / `startServer` / `stopServer` / `getNodePoolSize`)。
## 回调与线程
- `onTorrent` / `onError`:在 Rust 工作线程触发,Java 实现须线程安全。
- `onMetadataFetch`:在阻塞线程池中调用,应尽快返回 boolean。
行为与 Rust 库一致:InfoHash 来自 **`announce_peer`**`start()` 在 Rust 侧仍阻塞至 `shutdown()`JNI 通过单独 runtime + `spawn` 避免卡住 Java 主流程。
当前 JNI listener 暴露 `onTorrent``onMetadataFetch``onError`,不暴露 Rust
`on_torrent_with_ack` / `on_metadata_fetch_complete`。因此 Java `onTorrent` 返回后始终按
`Accepted` 处理;需要交付确认和最终状态的应用应扩展 JNI callback contract。
## JNI 配置映射
Java `DHTOptions` 是 Rust 配置的扁平化子集,不是全部 Rust 字段的一一镜像:
| Java 字段组 | Rust 目标 |
|---|---|
| port / netMode / hashQueueCapacity | `DHTOptions` 顶层 |
| metadataTimeout / maxMetadataQueueSize / maxMetadataWorkerCount | `metadata.*` |
| poolCapacity / recentProbeTtlSeconds / responsive* / poolLowWatermark | `crawl.pool.*` |
| findNode* / requestTimeout* / response* / pressure / replacements / subnet | `crawl.rate_limit.*` |
以下配置未通过当前 JNI 暴露,使用 Rust `Default`
- `metadata.peer_failure_cache_capacity``metadata.peer_failure_ttl_secs`
- `crawl.bootstrap.*`
- `crawl.target.*`
- `crawl.scheduler.*`
Java 字段默认值与当前 Rust 0.2 默认值保持一致;传入 `null` options 时直接使用完整的
`DHTOptions::default()`
## 注意事项
- 回调在 Rust 工作线程触发,实现需线程安全
- `onMetadataFetch` 在阻塞线程池调用,宜快速返回
- Java 11+(见 `build.gradle`
- Java/Rust 版本必须一致,避免 JNI 按字段名和签名读取时失败
- 停止后勿再使用同一 `long` 句柄。
@@ -1,8 +1,9 @@
package cn.lmcw.dht.model;
/**
* Rust {@code DHTOptions} 一一对应的配置对象
* <p>通过 JNI 传入 Rust 侧,由 Rust 读取各字段构造 {@code DHTOptions}。</p>
* Rust {@code DHTOptions} 的 JNI 扁平化子集
* <p>通过 JNI 传入 Rust 侧;未暴露的 Metadata failure cache、Bootstrap、Target 和
* Scheduler 字段采用 Rust 0.2 默认值。Java 与 native 库版本必须保持一致。</p>
*
* <h3>netMode 取值</h3>
* <ul>
@@ -17,16 +18,37 @@ public final class DHTOptions {
private int port = 6881;
/** 获取 metadata 超时(秒),默认 3 */
private long metadataTimeout = 3L;
private long metadataTimeout = 4L;
/** metadata 队列最大容量,默认 100000 */
private int maxMetadataQueueSize = 100_000;
private int maxMetadataQueueSize = 10_000;
/** 并发 metadata 拉取 worker 数量,默认 1000 */
private int maxMetadataWorkerCount = 1_000;
private int maxMetadataWorkerCount = 256;
/** 节点队列容量,默认 100000 */
private int nodeQueueCapacity = 100_000;
private int poolCapacity = 100_000;
private int findNodeRatePerSecond = 200;
private int findNodeBurst = 40;
private int maxFindNodeInFlight = 512;
private int maxNewDestinationsPerMinute = 10_000;
private int maxReplacementsPerMinute = 25_000;
private long requestTimeoutSeconds = 2L;
private int maxResponseRatePerSecond = 500;
private long maxResponseBytesPerSecond = 1_048_576L;
private int maxResponseRatePerSource = 40;
private int metadataPressureFloorPercent = 25;
private long recentProbeTtlSeconds = 600L;
private int responsiveCapacity = 16_384;
private long responsiveTtlSeconds = 900L;
private int poolLowWatermark = 10_000;
private int maxInFlightPerSubnet = 8;
/** hash 队列容量,默认 10000 */
private int hashQueueCapacity = 10_000;
@@ -60,12 +82,48 @@ public final class DHTOptions {
return this;
}
public int getNodeQueueCapacity() { return nodeQueueCapacity; }
public DHTOptions setNodeQueueCapacity(int nodeQueueCapacity) {
this.nodeQueueCapacity = nodeQueueCapacity;
public int getPoolCapacity() { return poolCapacity; }
public DHTOptions setPoolCapacity(int poolCapacity) {
this.poolCapacity = poolCapacity;
return this;
}
public int getFindNodeRatePerSecond() { return findNodeRatePerSecond; }
public DHTOptions setFindNodeRatePerSecond(int value) { this.findNodeRatePerSecond = value; return this; }
public int getFindNodeBurst() { return findNodeBurst; }
public DHTOptions setFindNodeBurst(int value) { this.findNodeBurst = value; return this; }
public int getMaxFindNodeInFlight() { return maxFindNodeInFlight; }
public DHTOptions setMaxFindNodeInFlight(int value) { this.maxFindNodeInFlight = value; return this; }
public int getMaxNewDestinationsPerMinute() { return maxNewDestinationsPerMinute; }
public DHTOptions setMaxNewDestinationsPerMinute(int value) { this.maxNewDestinationsPerMinute = value; return this; }
public int getMaxReplacementsPerMinute() { return maxReplacementsPerMinute; }
public DHTOptions setMaxReplacementsPerMinute(int value) { this.maxReplacementsPerMinute = value; return this; }
public long getRequestTimeoutSeconds() { return requestTimeoutSeconds; }
public DHTOptions setRequestTimeoutSeconds(long value) { this.requestTimeoutSeconds = value; return this; }
public int getMaxResponseRatePerSecond() { return maxResponseRatePerSecond; }
public DHTOptions setMaxResponseRatePerSecond(int value) { this.maxResponseRatePerSecond = value; return this; }
public long getMaxResponseBytesPerSecond() { return maxResponseBytesPerSecond; }
public DHTOptions setMaxResponseBytesPerSecond(long value) { this.maxResponseBytesPerSecond = value; return this; }
public int getMaxResponseRatePerSource() { return maxResponseRatePerSource; }
public DHTOptions setMaxResponseRatePerSource(int value) { this.maxResponseRatePerSource = value; return this; }
public int getMetadataPressureFloorPercent() { return metadataPressureFloorPercent; }
public DHTOptions setMetadataPressureFloorPercent(int value) { this.metadataPressureFloorPercent = value; return this; }
public long getRecentProbeTtlSeconds() { return recentProbeTtlSeconds; }
public DHTOptions setRecentProbeTtlSeconds(long value) { this.recentProbeTtlSeconds = value; return this; }
public int getResponsiveCapacity() { return responsiveCapacity; }
public DHTOptions setResponsiveCapacity(int value) { this.responsiveCapacity = value; return this; }
public long getResponsiveTtlSeconds() { return responsiveTtlSeconds; }
public DHTOptions setResponsiveTtlSeconds(long value) { this.responsiveTtlSeconds = value; return this; }
public int getPoolLowWatermark() { return poolLowWatermark; }
public DHTOptions setPoolLowWatermark(int value) { this.poolLowWatermark = value; return this; }
public int getMaxInFlightPerSubnet() { return maxInFlightPerSubnet; }
public DHTOptions setMaxInFlightPerSubnet(int value) { this.maxInFlightPerSubnet = value; return this; }
public int getHashQueueCapacity() { return hashQueueCapacity; }
public DHTOptions setHashQueueCapacity(int hashQueueCapacity) {
this.hashQueueCapacity = hashQueueCapacity;
+34 -7
View File
@@ -34,11 +34,14 @@ async fn main() -> Result<()> {
let options = DHTOptions {
port: 12313,
metadata_timeout: 3, // ✅ 快速超时,快速失败
max_metadata_queue_size: 100000, // ✅ 大缓冲区(防止饱和)
max_metadata_worker_count: 1000, // ✅ 激进并发(最大化吞吐)
netmode: NetMode::Ipv4Only, // 网络模式:Ipv4Only(仅IPv4)、Ipv6Only(仅IPv6)、DualStack(双栈,默认)
..Default::default() // 使用默认值填充其他字段(节点队列容量等)
netmode: NetMode::Ipv4Only,
metadata: MetadataOptions {
timeout_secs: 4,
max_queue_size: 10_000,
max_worker_count: 256,
..MetadataOptions::default()
},
..Default::default()
};
// 统计计数器
@@ -83,8 +86,19 @@ async fn main() -> Result<()> {
// 设置元数据获取前的检查回调
server.on_metadata_fetch(|_hash| async move { true });
// 一个通过 gate 的 Hash 最终只会收到一次完成状态。
server.on_metadata_fetch_complete(|completion| {
log::debug!(
"Metadata complete: hash={}, status={:?}, peer_attempts={}",
completion.info_hash,
completion.status,
completion.attempts
);
});
// 启动监控任务
let count_monitor = torrent_count.clone();
let runtime_stats = server.runtime_stats();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
let start_time = std::time::Instant::now();
@@ -93,12 +107,17 @@ async fn main() -> Result<()> {
interval.tick().await;
let success_fetch = count_monitor.load(Ordering::Relaxed);
let uptime = start_time.elapsed().as_secs();
let runtime = runtime_stats.snapshot();
// ✅ 监控:爬虫运行状态
log::info!(
"📊 [监控] 时长: {}s | 成功抓取: ✨ {}",
"📊 [监控] 时长: {}s | 成功抓取: ✨ {} | 节点: {} | Metadata: {}/{} | worker: {}",
uptime,
success_fetch
success_fetch,
runtime.node_pool_size,
runtime.metadata_queue_depth,
runtime.metadata_queue_max,
runtime.metadata_in_flight,
);
if uptime > 0 && success_fetch > 0 {
@@ -108,6 +127,14 @@ async fn main() -> Result<()> {
}
});
let shutdown_server = server.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
log::info!("收到 Ctrl-C,正在停止 DHT Server");
shutdown_server.shutdown();
}
});
server.start().await?;
Ok(())
}
+1 -1
View File
@@ -71,7 +71,7 @@ fn register_on_metadata_fetch(server: &Arc<DHTServer>, callback: JavaCallback) {
"(Ljava/lang/String;)Z",
&[JValue::Object(&j_s.into())],
)?;
Ok(out.z()?)
out.z()
})
})
.await;
+5 -5
View File
@@ -1,5 +1,5 @@
use jni::{JavaVM, JNIEnv};
use jni::objects::JObject;
use jni::{JNIEnv, JavaVM};
use std::sync::{Arc, Mutex};
/// 跨线程安全的 Java 回调持有者。
@@ -33,10 +33,10 @@ impl JavaCallback {
let listener_guard = self.listener.lock().unwrap();
let result = f(&mut guard, listener_guard.as_obj());
// 检查并清除 Java 侧遗留的异常,避免后续 JNI 调用受污染
if let Err(ref _e) = result {
if guard.exception_check().unwrap_or(false) {
let _ = guard.exception_clear();
}
if let Err(ref _e) = result
&& guard.exception_check().unwrap_or(false)
{
let _ = guard.exception_clear();
}
result
}
+3 -3
View File
@@ -1,6 +1,6 @@
use crate::jni_bindings::callbacks::register_callbacks;
use crate::jni_bindings::env::JavaCallback;
use crate::jni_bindings::server::{handle_ref, into_handle_ptr, take_handle, ServerHandle};
use crate::jni_bindings::server::{ServerHandle, handle_ref, into_handle_ptr, take_handle};
use crate::jni_bindings::types::java_to_dht_options_or_default;
use jni::JNIEnv;
use jni::objects::{JClass, JObject};
@@ -29,7 +29,7 @@ pub extern "system" fn Java_cn_lmcw_dht_DhtCrawlerJni_createServer(
let opts = match java_to_dht_options_or_default(&mut env, &options) {
Ok(o) => o,
Err(e) => {
let _ = env.throw_new("java/lang/IllegalArgumentException", &e.to_string());
let _ = env.throw_new("java/lang/IllegalArgumentException", e.to_string());
return 0;
}
};
@@ -48,7 +48,7 @@ pub extern "system" fn Java_cn_lmcw_dht_DhtCrawlerJni_createServer(
match JavaCallback::new(&mut env, &listener) {
Ok(cb) => register_callbacks(&handle.server, cb),
Err(e) => {
let _ = env.throw_new("java/lang/RuntimeException", &e.to_string());
let _ = env.throw_new("java/lang/RuntimeException", e.to_string());
return 0;
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::{DHTServer, DHTOptions};
use crate::{DHTOptions, DHTServer};
use std::sync::Arc;
use tokio::runtime::Runtime;
+63 -17
View File
@@ -1,4 +1,4 @@
use crate::{TorrentInfo, FileInfo, DHTOptions, types::NetMode};
use crate::{DHTOptions, FileInfo, MetadataOptions, TorrentInfo, types::NetMode};
use jni::JNIEnv;
use jni::objects::{JObject, JString, JValue};
use jni::sys::jlong;
@@ -90,7 +90,12 @@ fn build_file_list<'local>(
let list = env.new_object(&list_cls, "()V", &[])?;
for fi in files {
let jfi = file_info_to_java(env, fi)?;
env.call_method(&list, "add", "(Ljava/lang/Object;)Z", &[JValue::Object(&jfi)])?;
env.call_method(
&list,
"add",
"(Ljava/lang/Object;)Z",
&[JValue::Object(&jfi)],
)?;
env.delete_local_ref(jfi)?;
}
Ok(list)
@@ -105,7 +110,12 @@ fn build_string_list<'local>(
let list = env.new_object(&list_cls, "()V", &[])?;
for s in strs {
let js = rust_str_to_jstring(env, s)?;
env.call_method(&list, "add", "(Ljava/lang/Object;)Z", &[JValue::Object(&js)])?;
env.call_method(
&list,
"add",
"(Ljava/lang/Object;)Z",
&[JValue::Object(&js)],
)?;
env.delete_local_ref(js)?;
}
Ok(list)
@@ -118,15 +128,31 @@ fn build_string_list<'local>(
/// 从 Java `cn.lmcw.dht.model.DHTOptions` 对象读取字段,构造 Rust `DHTOptions`。
pub fn java_to_dht_options(env: &mut JNIEnv, obj: &JObject) -> jni::errors::Result<DHTOptions> {
let port = env.get_field(obj, "port", "I")?.i()? as u16;
let metadata_timeout = env.get_field(obj, "metadataTimeout", "J")?.j()? as u64;
let max_metadata_queue_size =
env.get_field(obj, "maxMetadataQueueSize", "I")?.i()? as usize;
let max_metadata_worker_count =
let metadata_timeout_secs = env.get_field(obj, "metadataTimeout", "J")?.j()? as u64;
let metadata_max_queue_size = env.get_field(obj, "maxMetadataQueueSize", "I")?.i()? as usize;
let metadata_max_worker_count =
env.get_field(obj, "maxMetadataWorkerCount", "I")?.i()? as usize;
let node_queue_capacity =
env.get_field(obj, "nodeQueueCapacity", "I")?.i()? as usize;
let hash_queue_capacity =
env.get_field(obj, "hashQueueCapacity", "I")?.i()? as usize;
let pool_capacity = env.get_field(obj, "poolCapacity", "I")?.i()? as usize;
let find_node_rate = env.get_field(obj, "findNodeRatePerSecond", "I")?.i()? as u32;
let find_node_burst = env.get_field(obj, "findNodeBurst", "I")?.i()? as u32;
let max_find_node_in_flight = env.get_field(obj, "maxFindNodeInFlight", "I")?.i()? as usize;
let max_new_destinations = env
.get_field(obj, "maxNewDestinationsPerMinute", "I")?
.i()? as u32;
let max_replacements = env.get_field(obj, "maxReplacementsPerMinute", "I")?.i()? as u32;
let request_timeout_secs = env.get_field(obj, "requestTimeoutSeconds", "J")?.j()? as u64;
let max_response_rate = env.get_field(obj, "maxResponseRatePerSecond", "I")?.i()? as u32;
let max_response_bytes = env.get_field(obj, "maxResponseBytesPerSecond", "J")?.j()? as u64;
let max_response_per_source = env.get_field(obj, "maxResponseRatePerSource", "I")?.i()? as u32;
let pressure_floor = env
.get_field(obj, "metadataPressureFloorPercent", "I")?
.i()? as u8;
let recent_probe_ttl = env.get_field(obj, "recentProbeTtlSeconds", "J")?.j()? as u64;
let responsive_capacity = env.get_field(obj, "responsiveCapacity", "I")?.i()? as usize;
let responsive_ttl = env.get_field(obj, "responsiveTtlSeconds", "J")?.j()? as u64;
let low_watermark = env.get_field(obj, "poolLowWatermark", "I")?.i()? as usize;
let subnet_in_flight = env.get_field(obj, "maxInFlightPerSubnet", "I")?.i()? as usize;
let hash_queue_capacity = env.get_field(obj, "hashQueueCapacity", "I")?.i()? as usize;
let netmode_ord = env.get_field(obj, "netMode", "I")?.i()?;
let netmode = match netmode_ord {
0 => NetMode::Ipv4Only,
@@ -134,15 +160,35 @@ pub fn java_to_dht_options(env: &mut JNIEnv, obj: &JObject) -> jni::errors::Resu
_ => NetMode::DualStack,
};
Ok(DHTOptions {
let mut options = DHTOptions {
port,
metadata_timeout,
max_metadata_queue_size,
max_metadata_worker_count,
netmode,
node_queue_capacity,
hash_queue_capacity,
})
metadata: MetadataOptions {
timeout_secs: metadata_timeout_secs,
max_queue_size: metadata_max_queue_size,
max_worker_count: metadata_max_worker_count,
..MetadataOptions::default()
},
..DHTOptions::default()
};
options.crawl.pool.capacity = pool_capacity.max(1);
options.crawl.pool.recent_probe_ttl_secs = recent_probe_ttl;
options.crawl.pool.responsive_capacity = responsive_capacity.max(1);
options.crawl.pool.responsive_ttl_secs = responsive_ttl;
options.crawl.pool.low_watermark = low_watermark.min(pool_capacity);
options.crawl.rate_limit.max_find_node_rate_per_sec = find_node_rate;
options.crawl.rate_limit.burst = find_node_burst;
options.crawl.rate_limit.max_in_flight = max_find_node_in_flight.max(1);
options.crawl.rate_limit.max_new_destinations_per_minute = max_new_destinations;
options.crawl.rate_limit.request_timeout_secs = request_timeout_secs;
options.crawl.rate_limit.max_response_rate_per_sec = max_response_rate;
options.crawl.rate_limit.max_response_bytes_per_sec = max_response_bytes;
options.crawl.rate_limit.max_response_rate_per_source = max_response_per_source;
options.crawl.rate_limit.metadata_pressure_floor_percent = pressure_floor.min(100);
options.crawl.rate_limit.max_replacements_per_minute = max_replacements;
options.crawl.rate_limit.max_in_flight_per_subnet = subnet_in_flight.max(1);
Ok(options)
}
/// 从 Java `cn.lmcw.dht.model.DHTOptions` 对象读取,或若为 null 则返回默认选项。
+71
View File
@@ -0,0 +1,71 @@
use crate::types::NetMode;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
pub(crate) fn addr_allowed_by_netmode(addr: &SocketAddr, netmode: NetMode) -> bool {
match netmode {
NetMode::Ipv4Only => addr.is_ipv4(),
NetMode::Ipv6Only => addr.is_ipv6(),
NetMode::DualStack => true,
}
}
pub(crate) fn is_valid_node_addr(addr: &SocketAddr) -> bool {
if addr.port() == 0 {
return false;
}
match addr.ip() {
IpAddr::V4(ip) => is_valid_ipv4_node_addr(ip),
IpAddr::V6(ip) => is_valid_ipv6_node_addr(ip),
}
}
fn is_valid_ipv4_node_addr(ip: Ipv4Addr) -> bool {
let octets = ip.octets();
let is_cgnat = octets[0] == 100 && (octets[1] & 0b1100_0000) == 64;
let is_benchmark = octets[0] == 198 && (octets[1] == 18 || octets[1] == 19);
let is_reserved = octets[0] >= 240;
!ip.is_unspecified()
&& !ip.is_loopback()
&& !ip.is_private()
&& !ip.is_link_local()
&& !ip.is_multicast()
&& !ip.is_broadcast()
&& !ip.is_documentation()
&& !is_cgnat
&& !is_benchmark
&& !is_reserved
}
fn is_valid_ipv6_node_addr(ip: Ipv6Addr) -> bool {
let octets = ip.octets();
let is_unique_local = (octets[0] & 0xfe) == 0xfc;
let is_unicast_link_local = octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80;
let is_documentation =
octets[0] == 0x20 && octets[1] == 0x01 && octets[2] == 0x0d && octets[3] == 0xb8;
!ip.is_unspecified()
&& !ip.is_loopback()
&& !ip.is_multicast()
&& !is_unique_local
&& !is_unicast_link_local
&& !is_documentation
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_node_addresses_are_filtered() {
assert!(is_valid_node_addr(&"8.8.8.8:6881".parse().unwrap()));
assert!(is_valid_node_addr(
&"[2001:4860:4860::8888]:6881".parse().unwrap()
));
assert!(!is_valid_node_addr(&"8.8.8.8:0".parse().unwrap()));
assert!(!is_valid_node_addr(&"10.0.0.1:6881".parse().unwrap()));
assert!(!is_valid_node_addr(&"127.0.0.1:6881".parse().unwrap()));
assert!(!is_valid_node_addr(&"[fc00::1]:6881".parse().unwrap()));
}
}
+221
View File
@@ -0,0 +1,221 @@
use crate::addr::{addr_allowed_by_netmode, is_valid_node_addr};
use crate::crawl_config::ResolvedCrawlConfig;
use crate::types::NetMode;
use std::collections::{HashMap, HashSet};
use std::net::SocketAddr;
use std::time::{Duration, Instant};
pub(crate) struct BootstrapGate {
last_bootstrap: Option<Instant>,
}
impl BootstrapGate {
pub(crate) fn new() -> Self {
Self {
last_bootstrap: None,
}
}
pub(crate) fn should_bootstrap(
&mut self,
pool_len: usize,
config: &ResolvedCrawlConfig,
now: Instant,
) -> bool {
if config.bootstrap_max_nodes_per_round == 0 {
return false;
}
if pool_len >= config.low_watermark {
return false;
}
if let Some(last) = self.last_bootstrap
&& now.checked_duration_since(last).unwrap_or_default() < config.bootstrap_interval
{
return false;
}
self.last_bootstrap = Some(now);
true
}
}
#[derive(Default)]
struct BootstrapSourceState {
last_attempt: Option<Instant>,
last_success: Option<Instant>,
fail_count: u32,
backoff_until: Option<Instant>,
}
pub(crate) struct BootstrapSourcePool {
pub(crate) hosts: Vec<String>,
states: HashMap<SocketAddr, BootstrapSourceState>,
backoff_base: Duration,
backoff_max: Duration,
}
impl BootstrapSourcePool {
pub(crate) fn new(hosts: Vec<String>, backoff_base: Duration, backoff_max: Duration) -> Self {
Self {
hosts,
states: HashMap::new(),
backoff_base,
backoff_max,
}
}
pub(crate) fn select(
&mut self,
candidates: Vec<SocketAddr>,
max_nodes: usize,
now: Instant,
) -> Vec<SocketAddr> {
let mut selected = Vec::with_capacity(max_nodes);
let mut seen = HashSet::with_capacity(candidates.len());
let mut earliest_backoff: Option<(SocketAddr, Instant)> = None;
for addr in candidates {
if !seen.insert(addr) {
continue;
}
let state = self.states.entry(addr).or_default();
if let Some(backoff_until) = state.backoff_until
&& backoff_until > now
{
if earliest_backoff.is_none_or(|(_, current)| backoff_until < current) {
earliest_backoff = Some((addr, backoff_until));
}
continue;
}
selected.push(addr);
if selected.len() >= max_nodes {
return selected;
}
}
if selected.is_empty()
&& max_nodes > 0
&& let Some((addr, _)) = earliest_backoff
{
selected.push(addr);
}
selected
}
pub(crate) fn mark_attempt(&mut self, addr: SocketAddr, now: Instant) {
self.states.entry(addr).or_default().last_attempt = Some(now);
}
pub(crate) fn mark_success(&mut self, addr: SocketAddr, now: Instant) {
let state = self.states.entry(addr).or_default();
state.last_success = Some(now);
state.fail_count = 0;
state.backoff_until = None;
}
pub(crate) fn mark_timeout(&mut self, addr: SocketAddr, now: Instant) {
let state = self.states.entry(addr).or_default();
state.fail_count = state.fail_count.saturating_add(1);
let multiplier = 1u32
.checked_shl(state.fail_count.saturating_sub(1).min(16))
.unwrap_or(u32::MAX);
let backoff = self
.backoff_base
.saturating_mul(multiplier)
.min(self.backoff_max);
state.backoff_until = Some(now + backoff);
}
}
pub(crate) async fn resolve_bootstrap_nodes(hosts: &[String], netmode: NetMode) -> Vec<SocketAddr> {
let mut resolved = Vec::new();
for host in hosts {
if let Ok(addrs) = tokio::net::lookup_host(host).await {
for addr in addrs {
if !addr_allowed_by_netmode(&addr, netmode) || !is_valid_node_addr(&addr) {
continue;
}
resolved.push(addr);
}
}
}
resolved
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::CrawlOptions;
fn test_config() -> ResolvedCrawlConfig {
ResolvedCrawlConfig::from_options(&CrawlOptions::default())
}
#[test]
fn bootstrap_pool_backs_off_dead_sources_without_spending_quota() {
let start = Instant::now();
let addr1: SocketAddr = "8.8.8.8:6881".parse().unwrap();
let addr2: SocketAddr = "1.1.1.1:6881".parse().unwrap();
let mut pool = BootstrapSourcePool::new(
vec!["example.invalid:6881".to_string()],
Duration::from_secs(300),
Duration::from_secs(3600),
);
pool.mark_timeout(addr1, start);
let selected = pool.select(vec![addr1, addr2], 1, start + Duration::from_secs(1));
assert_eq!(selected, vec![addr2]);
pool.mark_success(addr1, start + Duration::from_secs(2));
let selected = pool.select(vec![addr1], 1, start + Duration::from_secs(3));
assert_eq!(selected, vec![addr1]);
}
#[test]
fn bootstrap_pool_forces_one_retry_when_all_sources_backed_off() {
let start = Instant::now();
let addr1: SocketAddr = "8.8.8.8:6881".parse().unwrap();
let addr2: SocketAddr = "1.1.1.1:6881".parse().unwrap();
let mut pool = BootstrapSourcePool::new(
vec!["example.invalid:6881".to_string()],
Duration::from_secs(300),
Duration::from_secs(3600),
);
pool.mark_timeout(addr1, start);
pool.mark_timeout(addr2, start);
let selected = pool.select(vec![addr1, addr2], 3, start + Duration::from_secs(1));
assert_eq!(selected.len(), 1);
}
#[test]
fn bootstrap_pool_deduplicates_resolved_addresses() {
let start = Instant::now();
let addr: SocketAddr = "8.8.8.8:6881".parse().unwrap();
let mut pool = BootstrapSourcePool::new(
vec!["example.invalid:6881".to_string()],
Duration::from_secs(300),
Duration::from_secs(3600),
);
let selected = pool.select(vec![addr, addr], 10, start);
assert_eq!(selected, vec![addr]);
}
#[test]
fn bootstrap_gate_uses_pool_low_water_mark() {
let start = Instant::now();
let config = test_config();
let mut gate = BootstrapGate::new();
assert!(!gate.should_bootstrap(config.low_watermark, &config, start));
assert!(gate.should_bootstrap(0, &config, start));
assert!(!gate.should_bootstrap(
999,
&config,
start + config.bootstrap_interval - Duration::from_secs(1)
));
assert!(gate.should_bootstrap(999, &config, start + config.bootstrap_interval));
}
}
+132
View File
@@ -0,0 +1,132 @@
use std::time::{Duration, Instant};
/// Single-owner token bucket. It deliberately contains no atomics or locks.
pub(crate) struct RateBucket {
rate_per_sec: f64,
capacity: f64,
tokens: f64,
last_refill: Instant,
}
impl RateBucket {
pub(crate) fn per_second(
rate_per_sec: u32,
burst: u32,
initially_full: bool,
now: Instant,
) -> Self {
Self::new(
f64::from(rate_per_sec),
f64::from(burst),
initially_full,
now,
)
}
pub(crate) fn per_minute(
rate_per_minute: u32,
burst: u32,
initially_full: bool,
now: Instant,
) -> Self {
Self::new(
f64::from(rate_per_minute) / 60.0,
f64::from(burst),
initially_full,
now,
)
}
fn new(rate_per_sec: f64, capacity: f64, initially_full: bool, now: Instant) -> Self {
let capacity = if rate_per_sec <= 0.0 {
0.0
} else {
capacity.max(1.0)
};
Self {
rate_per_sec,
capacity,
tokens: if initially_full { capacity } else { 0.0 },
last_refill: now,
}
}
pub(crate) fn set_per_second_rate(&mut self, rate_per_sec: u32, now: Instant) {
self.refill(now);
self.rate_per_sec = f64::from(rate_per_sec);
if self.rate_per_sec <= 0.0 {
self.tokens = 0.0;
} else {
self.tokens = self.tokens.min(self.capacity);
}
}
pub(crate) fn try_take_one(&mut self, now: Instant) -> bool {
self.try_take_exact(1, now)
}
pub(crate) fn try_take_exact(&mut self, count: usize, now: Instant) -> bool {
if count == 0 {
return true;
}
if self.rate_per_sec <= 0.0 || self.capacity <= 0.0 {
return false;
}
self.refill(now);
if self.tokens < count as f64 {
return false;
}
self.tokens -= count as f64;
true
}
pub(crate) fn try_take(&mut self, max: usize, now: Instant) -> usize {
if max == 0 || self.rate_per_sec <= 0.0 || self.capacity <= 0.0 {
return 0;
}
self.refill(now);
let taken = (self.tokens.floor() as usize).min(max);
self.tokens -= taken as f64;
taken
}
pub(crate) fn refund_one(&mut self) {
self.refund(1);
}
pub(crate) fn refund(&mut self, count: usize) {
self.tokens = (self.tokens + count as f64).min(self.capacity);
}
fn refill(&mut self, now: Instant) {
let elapsed = now
.checked_duration_since(self.last_refill)
.unwrap_or(Duration::ZERO)
.as_secs_f64();
if elapsed > 0.0 {
self.tokens = (self.tokens + elapsed * self.rate_per_sec).min(self.capacity);
self.last_refill = now;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn second_bucket_is_smooth_and_bounded() {
let start = Instant::now();
let mut bucket = RateBucket::per_second(100, 20, false, start);
assert_eq!(bucket.try_take(100, start), 0);
assert_eq!(bucket.try_take(100, start + Duration::from_millis(100)), 10);
assert_eq!(bucket.try_take(100, start + Duration::from_secs(10)), 20);
}
#[test]
fn minute_bucket_refills_fractionally() {
let start = Instant::now();
let mut bucket = RateBucket::per_minute(600, 10, false, start);
assert_eq!(bucket.try_take(100, start + Duration::from_millis(500)), 5);
}
}
+137
View File
@@ -0,0 +1,137 @@
use crate::types::CrawlOptions;
use std::time::Duration;
#[derive(Debug, Clone)]
pub(crate) struct ResolvedCrawlConfig {
pub(crate) max_find_node_rate_per_sec: u32,
pub(crate) burst: u32,
pub(crate) max_in_flight: usize,
pub(crate) request_timeout: Duration,
pub(crate) max_new_destinations_per_minute: u32,
pub(crate) max_response_rate_per_sec: u32,
pub(crate) max_response_bytes_per_sec: u64,
pub(crate) max_response_rate_per_source: u32,
pub(crate) metadata_pressure_floor_percent: u8,
pub(crate) pool_capacity: usize,
pub(crate) max_replacements_per_minute: u32,
pub(crate) recent_probe_ttl: Duration,
pub(crate) responsive_capacity: usize,
pub(crate) responsive_ttl: Duration,
pub(crate) low_watermark: usize,
pub(crate) max_in_flight_per_subnet: usize,
pub(crate) bootstrap_nodes: Vec<String>,
pub(crate) bootstrap_interval: Duration,
pub(crate) bootstrap_max_nodes_per_round: usize,
pub(crate) bootstrap_backoff_base: Duration,
pub(crate) bootstrap_backoff_max: Duration,
pub(crate) random_walk_percent: u8,
pub(crate) sparse_bucket_percent: u8,
pub(crate) neighbor_sender_id: bool,
pub(crate) priority_event_channel_capacity: usize,
pub(crate) discovery_event_channel_capacity: usize,
pub(crate) event_batch_limit: usize,
pub(crate) node_batch_limit: usize,
pub(crate) routing_snapshot_size: usize,
pub(crate) snapshot_refresh: Duration,
}
impl ResolvedCrawlConfig {
pub(crate) fn from_options(options: &CrawlOptions) -> Self {
let capacity = options.pool.capacity.max(1);
Self {
max_find_node_rate_per_sec: options.rate_limit.max_find_node_rate_per_sec,
burst: if options.rate_limit.max_find_node_rate_per_sec == 0 {
0
} else {
options.rate_limit.burst.max(1)
},
max_in_flight: options.rate_limit.max_in_flight.max(1),
request_timeout: Duration::from_secs(options.rate_limit.request_timeout_secs.max(1)),
max_new_destinations_per_minute: options.rate_limit.max_new_destinations_per_minute,
max_response_rate_per_sec: options.rate_limit.max_response_rate_per_sec,
max_response_bytes_per_sec: options.rate_limit.max_response_bytes_per_sec,
max_response_rate_per_source: options.rate_limit.max_response_rate_per_source,
metadata_pressure_floor_percent: options
.rate_limit
.metadata_pressure_floor_percent
.min(100),
pool_capacity: capacity,
max_replacements_per_minute: options.rate_limit.max_replacements_per_minute,
recent_probe_ttl: Duration::from_secs(options.pool.recent_probe_ttl_secs.max(1)),
responsive_capacity: options.pool.responsive_capacity.max(1),
responsive_ttl: Duration::from_secs(options.pool.responsive_ttl_secs.max(1)),
low_watermark: options.pool.low_watermark.min(capacity),
max_in_flight_per_subnet: options.rate_limit.max_in_flight_per_subnet.max(1),
bootstrap_nodes: if options.bootstrap.nodes.is_empty() {
crate::types::BootstrapOptions::default().nodes
} else {
options.bootstrap.nodes.clone()
},
bootstrap_interval: Duration::from_secs(options.bootstrap.interval_secs),
bootstrap_max_nodes_per_round: options.bootstrap.max_nodes_per_round,
bootstrap_backoff_base: Duration::from_secs(
options.bootstrap.source_backoff_base_secs.max(1),
),
bootstrap_backoff_max: Duration::from_secs(
options.bootstrap.source_backoff_max_secs.max(1),
),
random_walk_percent: options.target.random_walk_percent.min(100),
sparse_bucket_percent: options.target.sparse_bucket_percent.min(100),
neighbor_sender_id: options.target.neighbor_sender_id,
priority_event_channel_capacity: options
.scheduler
.priority_event_channel_capacity
.max(1),
discovery_event_channel_capacity: options
.scheduler
.discovery_event_channel_capacity
.max(1),
event_batch_limit: options.scheduler.event_batch_limit.max(1),
node_batch_limit: options.scheduler.node_batch_limit.max(1),
routing_snapshot_size: options.scheduler.routing_snapshot_size.max(1),
snapshot_refresh: Duration::from_millis(
options.scheduler.snapshot_refresh_millis.max(100),
),
}
}
pub(crate) fn rate_for_metadata_pressure(&self, pressure: f64) -> u32 {
let max = f64::from(self.max_find_node_rate_per_sec);
if pressure < 0.80 {
return self.max_find_node_rate_per_sec;
}
let floor = max * f64::from(self.metadata_pressure_floor_percent) / 100.0;
if pressure >= 0.95 {
return floor.round() as u32;
}
let progress = ((pressure - 0.80) / 0.15).clamp(0.0, 1.0);
(max - (max - floor) * progress).round() as u32
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_pool_and_rate_limit() {
let mut options = CrawlOptions::default();
options.rate_limit.max_find_node_rate_per_sec = 200;
options.rate_limit.metadata_pressure_floor_percent = 25;
options.pool.capacity = 42;
let resolved = ResolvedCrawlConfig::from_options(&options);
assert_eq!(resolved.pool_capacity, 42);
assert_eq!(resolved.rate_for_metadata_pressure(0.79), 200);
assert_eq!(resolved.rate_for_metadata_pressure(0.95), 50);
assert_eq!(resolved.rate_for_metadata_pressure(1.0), 50);
}
}
+928
View File
@@ -0,0 +1,928 @@
use crate::bootstrap::{BootstrapGate, BootstrapSourcePool, resolve_bootstrap_nodes};
use crate::budget::RateBucket;
use crate::crawl_config::ResolvedCrawlConfig;
use crate::krpc::{for_each_response_node, send_find_node_query};
use crate::node_id::{
TransactionId, bucket_index, neighbor_node_id, random_node_id, target_for_bucket,
};
use crate::node_pool::{AdmissionOutcome, NodePool, ResponsiveReservoir, SubnetKey};
use crate::protocol::DhtResponse;
use crate::routing_snapshot::RoutingSnapshot;
#[cfg(test)]
use crate::runtime_stats::DhtRuntimeLimits;
use crate::runtime_stats::DhtRuntimeStats;
use crate::types::{NetMode, NodeTuple};
use ahash::AHashMap;
use arc_swap::ArcSwap;
use bytes::BytesMut;
#[cfg(feature = "metrics")]
use metrics::{counter, gauge};
use rand::Rng;
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
const SCHEDULE_INTERVAL: Duration = Duration::from_millis(5);
const MAX_POOL_SCAN_PER_SCHEDULE: usize = 256;
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
struct PendingKey {
addr: SocketAddr,
tid: TransactionId,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProbePurpose {
New,
Revisit,
Bootstrap,
}
#[derive(Debug, Clone, Copy)]
struct PendingRequest {
node: NodeTuple,
purpose: ProbePurpose,
deadline: Instant,
subnet: SubnetKey,
}
enum PriorityEvent {
Response {
remote_addr: SocketAddr,
tid: TransactionId,
response: DhtResponse,
},
SendFailed(PendingKey),
BootstrapResolved(Vec<SocketAddr>),
}
struct OutboundRequest {
key: PendingKey,
node: NodeTuple,
target: [u8; 20],
sender_id: [u8; 20],
}
pub(crate) struct CrawlEngine {
config: ResolvedCrawlConfig,
priority_tx: mpsc::Sender<PriorityEvent>,
discovery_tx: mpsc::Sender<NodeTuple>,
/// One-shot handoff used only by `DHTServer::start`; never touched by the crawl hot path.
receivers: Mutex<Option<(mpsc::Receiver<PriorityEvent>, mpsc::Receiver<NodeTuple>)>>,
pub(crate) snapshot: Arc<ArcSwap<RoutingSnapshot>>,
pub(crate) node_count: Arc<AtomicUsize>,
runtime_stats: DhtRuntimeStats,
}
impl CrawlEngine {
pub(crate) fn new(config: ResolvedCrawlConfig, runtime_stats: DhtRuntimeStats) -> Self {
let (priority_tx, priority_rx) = mpsc::channel(config.priority_event_channel_capacity);
let (discovery_tx, discovery_rx) = mpsc::channel(config.discovery_event_channel_capacity);
Self {
config,
priority_tx,
discovery_tx,
receivers: Mutex::new(Some((priority_rx, discovery_rx))),
snapshot: Arc::new(ArcSwap::from_pointee(RoutingSnapshot::default())),
node_count: Arc::new(AtomicUsize::new(0)),
runtime_stats,
}
}
pub(crate) fn route_discovered(&self, node: NodeTuple) {
let enqueue_result = self.discovery_tx.try_send(node);
self.runtime_stats.set_crawl_discovery_queue_depth(
self.discovery_tx
.max_capacity()
.saturating_sub(self.discovery_tx.capacity()),
);
if enqueue_result.is_err() {
self.runtime_stats.crawl_event_dropped_discovered();
#[cfg(feature = "metrics")]
counter!("dht_crawl_events_dropped_total", "kind" => "discovered").increment(1);
}
}
pub(crate) fn route_response(
&self,
remote_addr: SocketAddr,
tid: TransactionId,
response: DhtResponse,
) {
let enqueue_result = self.priority_tx.try_send(PriorityEvent::Response {
remote_addr,
tid,
response,
});
self.runtime_stats.set_crawl_priority_queue_depth(
self.priority_tx
.max_capacity()
.saturating_sub(self.priority_tx.capacity()),
);
if enqueue_result.is_err() {
self.runtime_stats.crawl_event_dropped_response();
#[cfg(feature = "metrics")]
counter!("dht_crawl_events_dropped_total", "kind" => "response").increment(1);
}
}
pub(crate) fn spawn(
&self,
netmode: NetMode,
local_id: [u8; 20],
sockets: &HashMap<SocketAddr, Arc<UdpSocket>>,
metadata_queue_len: Arc<AtomicUsize>,
max_metadata_queue_size: usize,
shutdown: CancellationToken,
) {
let Some((priority_rx, discovery_rx)) = self
.receivers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
else {
return;
};
let mut egress_v4 = None;
let mut egress_v6 = None;
for (bind_addr, socket) in sockets.iter() {
let (tx, rx) = mpsc::channel(self.config.max_in_flight.max(1));
spawn_egress(
socket.clone(),
rx,
self.priority_tx.clone(),
self.runtime_stats.clone(),
shutdown.clone(),
);
if bind_addr.is_ipv4() {
egress_v4 = Some(tx);
} else {
egress_v6 = Some(tx);
}
}
let actor = CrawlActor::new(CrawlActorInit {
config: self.config.clone(),
netmode,
local_id,
priority_rx,
discovery_rx,
priority_tx: self.priority_tx.clone(),
egress_v4,
egress_v6,
snapshot: self.snapshot.clone(),
node_count: self.node_count.clone(),
metadata_queue_len,
max_metadata_queue_size,
runtime_stats: self.runtime_stats.clone(),
shutdown,
});
tokio::spawn(actor.run());
}
}
fn spawn_egress(
socket: Arc<UdpSocket>,
mut rx: mpsc::Receiver<OutboundRequest>,
priority_tx: mpsc::Sender<PriorityEvent>,
runtime_stats: DhtRuntimeStats,
shutdown: CancellationToken,
) {
tokio::spawn(async move {
let mut buffer = BytesMut::with_capacity(128);
loop {
tokio::select! {
_ = shutdown.cancelled() => break,
request = rx.recv() => {
let Some(request) = request else { break };
if !send_find_node_query(
&request.node.addr,
&request.key.tid,
&request.target,
&request.sender_id,
&socket,
&mut buffer,
).await {
let _ = priority_tx.try_send(PriorityEvent::SendFailed(request.key));
runtime_stats.set_crawl_priority_queue_depth(
priority_tx
.max_capacity()
.saturating_sub(priority_tx.capacity()),
);
} else {
runtime_stats.udp_sent(buffer.len());
}
}
}
}
});
}
#[derive(Default)]
struct ActorMetrics {
admitted: u64,
replaced: u64,
duplicate: u64,
admission_limited: u64,
invalid: u64,
queries_new: u64,
queries_revisit: u64,
queries_bootstrap: u64,
responses: u64,
timeouts: u64,
send_failures: u64,
unmatched_responses: u64,
}
impl ActorMetrics {
fn record_admission(&mut self, outcome: AdmissionOutcome) {
match outcome {
AdmissionOutcome::Admitted => self.admitted += 1,
AdmissionOutcome::Replaced => self.replaced += 1,
AdmissionOutcome::Duplicate => self.duplicate += 1,
AdmissionOutcome::RateLimited => self.admission_limited += 1,
AdmissionOutcome::Invalid => self.invalid += 1,
}
}
}
fn record_runtime_admission(stats: &DhtRuntimeStats, outcome: AdmissionOutcome) {
match outcome {
AdmissionOutcome::Admitted => stats.node_admitted(),
AdmissionOutcome::Replaced => stats.node_replaced(),
AdmissionOutcome::Duplicate => stats.node_dropped_duplicate(),
AdmissionOutcome::RateLimited => stats.node_dropped_rate_limited(),
AdmissionOutcome::Invalid => stats.node_dropped_invalid(),
}
}
struct CrawlActorInit {
config: ResolvedCrawlConfig,
netmode: NetMode,
local_id: [u8; 20],
priority_rx: mpsc::Receiver<PriorityEvent>,
discovery_rx: mpsc::Receiver<NodeTuple>,
priority_tx: mpsc::Sender<PriorityEvent>,
egress_v4: Option<mpsc::Sender<OutboundRequest>>,
egress_v6: Option<mpsc::Sender<OutboundRequest>>,
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
node_count: Arc<AtomicUsize>,
metadata_queue_len: Arc<AtomicUsize>,
max_metadata_queue_size: usize,
runtime_stats: DhtRuntimeStats,
shutdown: CancellationToken,
}
struct CrawlActor {
config: ResolvedCrawlConfig,
netmode: NetMode,
local_id: [u8; 20],
priority_rx: mpsc::Receiver<PriorityEvent>,
discovery_rx: mpsc::Receiver<NodeTuple>,
priority_tx: mpsc::Sender<PriorityEvent>,
egress_v4: Option<mpsc::Sender<OutboundRequest>>,
egress_v6: Option<mpsc::Sender<OutboundRequest>>,
pool: NodePool,
responsive: ResponsiveReservoir,
pending: AHashMap<PendingKey, PendingRequest>,
pending_expiry: VecDeque<(Instant, PendingKey)>,
subnet_in_flight: AHashMap<SubnetKey, usize>,
query_budget: RateBucket,
destination_budget: RateBucket,
bootstrap_gate: BootstrapGate,
bootstrap_pool: BootstrapSourcePool,
bootstrap_queue: VecDeque<SocketAddr>,
snapshot: Arc<ArcSwap<RoutingSnapshot>>,
node_count: Arc<AtomicUsize>,
metadata_queue_len: Arc<AtomicUsize>,
max_metadata_queue_size: usize,
next_tid: u64,
metrics: ActorMetrics,
runtime_stats: DhtRuntimeStats,
shutdown: CancellationToken,
}
impl CrawlActor {
fn new(init: CrawlActorInit) -> Self {
let CrawlActorInit {
config,
netmode,
local_id,
priority_rx,
discovery_rx,
priority_tx,
egress_v4,
egress_v6,
snapshot,
node_count,
metadata_queue_len,
max_metadata_queue_size,
runtime_stats,
shutdown,
} = init;
let now = Instant::now();
let destination_burst = config.max_new_destinations_per_minute.div_ceil(60).max(1);
Self {
pool: NodePool::new(
config.pool_capacity,
config.max_replacements_per_minute,
config.recent_probe_ttl,
now,
),
responsive: ResponsiveReservoir::new(config.responsive_capacity, config.responsive_ttl),
pending: AHashMap::with_capacity(config.max_in_flight),
pending_expiry: VecDeque::with_capacity(config.max_in_flight),
subnet_in_flight: AHashMap::new(),
query_budget: RateBucket::per_second(
config.max_find_node_rate_per_sec,
config.burst,
false,
now,
),
destination_budget: RateBucket::per_minute(
config.max_new_destinations_per_minute,
destination_burst,
false,
now,
),
bootstrap_pool: BootstrapSourcePool::new(
config.bootstrap_nodes.clone(),
config.bootstrap_backoff_base,
config.bootstrap_backoff_max,
),
config,
netmode,
local_id,
priority_rx,
discovery_rx,
priority_tx,
egress_v4,
egress_v6,
bootstrap_gate: BootstrapGate::new(),
bootstrap_queue: VecDeque::new(),
snapshot,
node_count,
metadata_queue_len,
max_metadata_queue_size,
next_tid: 1,
metrics: ActorMetrics::default(),
runtime_stats,
shutdown,
}
}
async fn run(mut self) {
let mut schedule_tick = tokio::time::interval(SCHEDULE_INTERVAL);
schedule_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut snapshot_tick = tokio::time::interval(self.config.snapshot_refresh);
snapshot_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut metrics_tick = tokio::time::interval(Duration::from_secs(1));
metrics_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
_ = self.shutdown.cancelled() => break,
_ = schedule_tick.tick() => self.on_schedule_tick(Instant::now()),
_ = snapshot_tick.tick() => self.publish_snapshot(Instant::now()),
_ = metrics_tick.tick() => self.flush_metrics(Instant::now()),
event = self.priority_rx.recv() => {
self.runtime_stats
.set_crawl_priority_queue_depth(self.priority_rx.len());
let Some(event) = event else { break };
self.handle_priority(event, Instant::now());
self.drain_events();
}
node = self.discovery_rx.recv() => {
self.runtime_stats
.set_crawl_discovery_queue_depth(self.discovery_rx.len());
let Some(node) = node else { break };
self.admit(node, Instant::now());
self.drain_events();
}
}
}
self.runtime_stats.set_crawl_priority_queue_depth(0);
self.runtime_stats.set_crawl_discovery_queue_depth(0);
}
fn drain_events(&mut self) {
let mut events = 1;
let mut nodes = 0;
while events < self.config.event_batch_limit && nodes < self.config.node_batch_limit {
if events % 8 == 0
&& let Ok(node) = self.discovery_rx.try_recv()
{
self.runtime_stats
.set_crawl_discovery_queue_depth(self.discovery_rx.len());
self.admit(node, Instant::now());
events += 1;
nodes += 1;
continue;
}
if let Ok(event) = self.priority_rx.try_recv() {
self.runtime_stats
.set_crawl_priority_queue_depth(self.priority_rx.len());
self.handle_priority(event, Instant::now());
events += 1;
continue;
}
if let Ok(node) = self.discovery_rx.try_recv() {
self.runtime_stats
.set_crawl_discovery_queue_depth(self.discovery_rx.len());
self.admit(node, Instant::now());
events += 1;
nodes += 1;
continue;
}
break;
}
}
fn handle_priority(&mut self, event: PriorityEvent, now: Instant) {
match event {
PriorityEvent::Response {
remote_addr,
tid,
response,
} => self.handle_response(remote_addr, tid, response, now),
PriorityEvent::SendFailed(key) => {
if let Some(pending) = self.pending.remove(&key) {
self.release_pending(pending);
self.metrics.send_failures += 1;
self.runtime_stats.send_failure();
if pending.purpose == ProbePurpose::Bootstrap {
self.bootstrap_pool.mark_timeout(pending.node.addr, now);
}
}
}
PriorityEvent::BootstrapResolved(candidates) => {
let selected = self.bootstrap_pool.select(
candidates,
self.config.bootstrap_max_nodes_per_round,
now,
);
self.bootstrap_queue.extend(selected);
}
}
}
fn handle_response(
&mut self,
remote_addr: SocketAddr,
tid: TransactionId,
response: DhtResponse,
now: Instant,
) {
let key = PendingKey {
addr: remote_addr,
tid,
};
let Some(pending) = self.pending.remove(&key) else {
self.metrics.unmatched_responses += 1;
self.runtime_stats.unmatched_response();
return;
};
self.release_pending(pending);
self.metrics.responses += 1;
self.runtime_stats.response();
if pending.purpose == ProbePurpose::Bootstrap {
self.bootstrap_pool.mark_success(remote_addr, now);
}
let mut responsive_node = pending.node;
if let Some(id) = response.id.as_ref()
&& let Ok(id) = <[u8; 20]>::try_from(id.as_slice())
{
responsive_node.id = id;
}
self.responsive.record(responsive_node, now);
let pool = &mut self.pool;
let metrics = &mut self.metrics;
let runtime_stats = &self.runtime_stats;
for_each_response_node(&response, self.netmode, |node| {
let outcome = pool.admit(node, now);
metrics.record_admission(outcome);
record_runtime_admission(runtime_stats, outcome);
});
self.sync_node_count();
}
fn admit(&mut self, node: NodeTuple, now: Instant) {
let outcome = self.pool.admit(node, now);
self.metrics.record_admission(outcome);
record_runtime_admission(&self.runtime_stats, outcome);
self.sync_node_count();
}
fn on_schedule_tick(&mut self, now: Instant) {
self.expire_pending(now);
self.maybe_resolve_bootstrap(now);
let rate = self
.config
.rate_for_metadata_pressure(self.metadata_pressure());
self.runtime_stats.set_find_node_effective_rate(rate);
self.query_budget.set_per_second_rate(rate, now);
let budget = self.query_budget.try_take(self.config.burst as usize, now);
for _ in 0..budget {
if self.pending.len() >= self.config.max_in_flight {
self.query_budget.refund_one();
break;
}
if !self.schedule_one(now) {
self.query_budget.refund_one();
break;
}
}
self.sync_node_count();
}
fn schedule_one(&mut self, now: Instant) -> bool {
if self.pool.len() < self.config.low_watermark
&& let Some(addr) = self.bootstrap_queue.front().copied()
{
let is_new = !self.pool.contains_recent(&addr, now);
if is_new && !self.destination_budget.try_take_one(now) {
return self.schedule_revisit(now);
}
let node = NodeTuple {
id: self.local_id,
addr,
};
if self.try_dispatch(node, ProbePurpose::Bootstrap, now) {
self.bootstrap_queue.pop_front();
self.pool.record_probe(addr, now);
self.bootstrap_pool.mark_attempt(addr, now);
self.metrics.queries_bootstrap += 1;
self.runtime_stats.query_bootstrap();
return true;
}
if is_new {
self.destination_budget.refund_one();
}
}
let scan_limit = self.pool.len().min(MAX_POOL_SCAN_PER_SCHEDULE);
for _ in 0..scan_limit {
let Some(node) = self.pool.front() else {
break;
};
let subnet = SubnetKey::from_addr(&node.addr);
if self.subnet_count(&subnet) >= self.config.max_in_flight_per_subnet {
self.pool.rotate_front_to_back();
continue;
}
if !self.destination_budget.try_take_one(now) {
return self.schedule_revisit(now);
}
let node = self
.pool
.take_front_for_probe(now)
.expect("front node exists");
if self.try_dispatch(node, ProbePurpose::New, now) {
self.metrics.queries_new += 1;
self.runtime_stats.query_new();
return true;
}
self.pool.restore_front(node, now);
self.destination_budget.refund_one();
break;
}
self.schedule_revisit(now)
}
fn schedule_revisit(&mut self, now: Instant) -> bool {
let Some(node) = self.responsive.next_revisit(now) else {
return false;
};
let was_recent = self.pool.contains_recent(&node.addr, now);
if !was_recent && !self.destination_budget.try_take_one(now) {
return false;
}
if self.try_dispatch(node, ProbePurpose::Revisit, now) {
self.pool.record_probe(node.addr, now);
self.metrics.queries_revisit += 1;
self.runtime_stats.query_revisit();
return true;
}
if !was_recent {
self.destination_budget.refund_one();
}
false
}
fn try_dispatch(&mut self, node: NodeTuple, purpose: ProbePurpose, now: Instant) -> bool {
if self.pending.len() >= self.config.max_in_flight {
return false;
}
let subnet = SubnetKey::from_addr(&node.addr);
if self.subnet_count(&subnet) >= self.config.max_in_flight_per_subnet {
return false;
}
let tx = if node.addr.is_ipv4() {
self.egress_v4.clone()
} else {
self.egress_v6.clone()
};
let Some(tx) = tx else {
return false;
};
let Ok(permit) = tx.try_reserve() else {
return false;
};
let tid = self.next_tid.to_be_bytes();
self.next_tid = self.next_tid.wrapping_add(1).max(1);
let key = PendingKey {
addr: node.addr,
tid,
};
let deadline = now + self.config.request_timeout;
self.pending.insert(
key,
PendingRequest {
node,
purpose,
deadline,
subnet,
},
);
self.pending_expiry.push_back((deadline, key));
*self.subnet_in_flight.entry(subnet).or_insert(0) += 1;
self.runtime_stats
.set_find_node_in_flight(self.pending.len());
let sender_id = if self.config.neighbor_sender_id {
let generated = neighbor_node_id(&node.id, &self.local_id);
generated
.as_slice()
.try_into()
.expect("neighbor id is always 20 bytes")
} else {
self.local_id
};
permit.send(OutboundRequest {
key,
node,
target: self.choose_target(&node),
sender_id,
});
true
}
fn choose_target(&self, node: &NodeTuple) -> [u8; 20] {
let total = self
.config
.random_walk_percent
.saturating_add(self.config.sparse_bucket_percent)
.max(1);
if rand::thread_rng().gen_range(0..total) < self.config.sparse_bucket_percent {
target_for_bucket(&self.local_id, bucket_index(&node.id, &self.local_id))
} else {
random_node_id()
}
}
fn expire_pending(&mut self, now: Instant) {
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 entry exists");
self.release_pending(pending);
self.metrics.timeouts += 1;
self.runtime_stats.timeout();
if pending.purpose == ProbePurpose::Bootstrap {
self.bootstrap_pool.mark_timeout(pending.node.addr, now);
}
}
}
fn release_pending(&mut self, pending: PendingRequest) {
if let Some(count) = self.subnet_in_flight.get_mut(&pending.subnet) {
*count = count.saturating_sub(1);
if *count == 0 {
self.subnet_in_flight.remove(&pending.subnet);
}
}
self.runtime_stats
.set_find_node_in_flight(self.pending.len());
}
fn subnet_count(&self, subnet: &SubnetKey) -> usize {
self.subnet_in_flight.get(subnet).copied().unwrap_or(0)
}
fn maybe_resolve_bootstrap(&mut self, now: Instant) {
if !self.bootstrap_queue.is_empty()
|| !self
.bootstrap_gate
.should_bootstrap(self.pool.len(), &self.config, now)
{
return;
}
let hosts = self.bootstrap_pool.hosts.clone();
let netmode = self.netmode;
let priority_tx = self.priority_tx.clone();
let runtime_stats = self.runtime_stats.clone();
tokio::spawn(async move {
let resolved = resolve_bootstrap_nodes(&hosts, netmode).await;
let _ = priority_tx.try_send(PriorityEvent::BootstrapResolved(resolved));
runtime_stats.set_crawl_priority_queue_depth(
priority_tx
.max_capacity()
.saturating_sub(priority_tx.capacity()),
);
});
}
fn publish_snapshot(&self, now: Instant) {
let nodes = self
.responsive
.snapshot(self.config.routing_snapshot_size, now);
self.snapshot.store(Arc::new(RoutingSnapshot::from_nodes(
nodes,
self.config.routing_snapshot_size,
)));
}
fn metadata_pressure(&self) -> f64 {
if self.max_metadata_queue_size == 0 {
1.0
} else {
(self.metadata_queue_len.load(Ordering::Relaxed) as f64
/ self.max_metadata_queue_size as f64)
.min(1.0)
}
}
fn sync_node_count(&self) {
self.node_count.store(self.pool.len(), Ordering::Relaxed);
self.runtime_stats.set_node_pool_size(self.pool.len());
}
fn flush_metrics(&mut self, now: Instant) {
#[cfg(feature = "metrics")]
{
let metadata_pressure = self.metadata_pressure();
gauge!("dht_node_pool_size").set(self.pool.len() as f64);
gauge!("dht_node_pool_oldest_age_seconds").set(self.pool.oldest_age(now).as_secs_f64());
gauge!("dht_find_node_in_flight").set(self.pending.len() as f64);
gauge!("dht_metadata_queue_pressure_ratio").set(metadata_pressure);
gauge!("dht_find_node_effective_rate_per_second")
.set(self.config.rate_for_metadata_pressure(metadata_pressure) as f64);
counter!("dht_node_pool_admissions_total").increment(self.metrics.admitted);
counter!("dht_node_pool_replacements_total").increment(self.metrics.replaced);
counter!("dht_node_pool_dropped_total", "reason" => "duplicate")
.increment(self.metrics.duplicate);
counter!("dht_node_pool_dropped_total", "reason" => "rate_limit")
.increment(self.metrics.admission_limited);
counter!("dht_node_pool_dropped_total", "reason" => "invalid")
.increment(self.metrics.invalid);
counter!("dht_crawl_queries_sent_total", "kind" => "new")
.increment(self.metrics.queries_new);
counter!("dht_crawl_queries_sent_total", "kind" => "revisit")
.increment(self.metrics.queries_revisit);
counter!("dht_crawl_queries_sent_total", "kind" => "bootstrap")
.increment(self.metrics.queries_bootstrap);
counter!("dht_find_node_responses_total").increment(self.metrics.responses);
counter!("dht_find_node_timeouts_total").increment(self.metrics.timeouts);
counter!("dht_find_node_send_failures_total").increment(self.metrics.send_failures);
counter!("dht_find_node_response_unmatched_total")
.increment(self.metrics.unmatched_responses);
}
#[cfg(not(feature = "metrics"))]
let _ = now;
self.metrics = ActorMetrics::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::CrawlOptions;
fn node(id: u8, addr: &str) -> NodeTuple {
NodeTuple {
id: [id; 20],
addr: addr.parse().unwrap(),
}
}
fn test_actor(
config: ResolvedCrawlConfig,
) -> (CrawlActor, mpsc::Receiver<OutboundRequest>, DhtRuntimeStats) {
let (priority_tx, priority_rx) = mpsc::channel(16);
let (_discovery_tx, discovery_rx) = mpsc::channel(16);
let (egress_tx, egress_rx) = mpsc::channel(config.max_in_flight);
let runtime_stats = DhtRuntimeStats::with_limits(DhtRuntimeLimits {
metadata_queue: 100_000,
node_pool: config.pool_capacity,
node_pool_low_watermark: config.low_watermark,
find_node_in_flight: config.max_in_flight,
initial_find_node_rate: config.max_find_node_rate_per_sec,
hash_ingress_queue: 0,
crawl_priority_queue: config.priority_event_channel_capacity,
crawl_discovery_queue: config.discovery_event_channel_capacity,
});
let actor = CrawlActor::new(CrawlActorInit {
config,
netmode: NetMode::Ipv4Only,
local_id: [7; 20],
priority_rx,
discovery_rx,
priority_tx,
egress_v4: Some(egress_tx),
egress_v6: None,
snapshot: Arc::new(ArcSwap::from_pointee(RoutingSnapshot::default())),
node_count: Arc::new(AtomicUsize::new(0)),
metadata_queue_len: Arc::new(AtomicUsize::new(0)),
max_metadata_queue_size: 100_000,
runtime_stats: runtime_stats.clone(),
shutdown: CancellationToken::new(),
});
(actor, egress_rx, runtime_stats)
}
#[test]
fn saturated_head_subnet_does_not_block_later_node() {
let config = ResolvedCrawlConfig::from_options(&CrawlOptions::default());
let max_per_subnet = config.max_in_flight_per_subnet;
let (mut actor, mut egress_rx, runtime_stats) = test_actor(config);
let now = Instant::now() + Duration::from_secs(1);
let blocked = node(1, "8.8.8.8:6881");
let eligible = node(2, "1.1.1.1:6881");
assert_eq!(actor.pool.admit(blocked, now), AdmissionOutcome::Admitted);
assert_eq!(actor.pool.admit(eligible, now), AdmissionOutcome::Admitted);
actor
.subnet_in_flight
.insert(SubnetKey::from_addr(&blocked.addr), max_per_subnet);
assert!(actor.schedule_one(now));
let request = egress_rx.try_recv().expect("eligible node was dispatched");
assert_eq!(request.node, eligible);
assert_eq!(actor.pool.front(), Some(blocked));
assert_eq!(actor.pool.admit(blocked, now), AdmissionOutcome::Duplicate);
assert!(!actor.pool.contains_recent(&blocked.addr, now));
assert!(actor.pool.contains_recent(&eligible.addr, now));
let snapshot = runtime_stats.snapshot();
assert_eq!(snapshot.queries_new, 1);
assert_eq!(snapshot.find_node_in_flight, 1);
assert_eq!(snapshot.find_node_in_flight_max, 512);
}
#[test]
fn runtime_stats_count_dropped_crawl_events() {
let mut options = CrawlOptions::default();
options.scheduler.priority_event_channel_capacity = 1;
options.scheduler.discovery_event_channel_capacity = 1;
let config = ResolvedCrawlConfig::from_options(&options);
let stats = DhtRuntimeStats::with_limits(DhtRuntimeLimits {
metadata_queue: 100,
node_pool: config.pool_capacity,
node_pool_low_watermark: config.low_watermark,
find_node_in_flight: config.max_in_flight,
initial_find_node_rate: config.max_find_node_rate_per_sec,
hash_ingress_queue: 0,
crawl_priority_queue: config.priority_event_channel_capacity,
crawl_discovery_queue: config.discovery_event_channel_capacity,
});
let engine = CrawlEngine::new(config, stats.clone());
engine.route_discovered(node(1, "8.8.8.8:1"));
engine.route_discovered(node(2, "1.1.1.1:2"));
let response = || DhtResponse {
id: None,
nodes: None,
nodes6: 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());
let snapshot = stats.snapshot();
assert_eq!(snapshot.crawl_events_dropped_discovered, 1);
assert_eq!(snapshot.crawl_events_dropped_response, 1);
assert_eq!(snapshot.crawl_discovery_queue_depth, 1);
assert_eq!(snapshot.crawl_discovery_queue_capacity, 1);
assert_eq!(snapshot.crawl_priority_queue_depth, 1);
assert_eq!(snapshot.crawl_priority_queue_capacity, 1);
}
}
+7
View File
@@ -1,21 +1,28 @@
use thiserror::Error;
#[derive(Error, Debug)]
/// Error returned during DHT initialization or execution.
pub enum DHTError {
/// Socket or other network I/O failed.
#[error("网络错误: {0}")]
Network(#[from] std::io::Error),
/// A shared lock was poisoned.
#[error("锁中毒: {0}")]
LockPoisoned(String),
/// Server initialization failed, for example because a socket could not bind.
#[error("初始化错误: {0}")]
Init(String),
/// An internal invariant or worker operation failed.
#[error("内部错误: {0}")]
Internal(String),
/// Another error represented by a human-readable message.
#[error("{0}")]
Other(String),
}
/// Result type used by the crate's public APIs.
pub type Result<T> = std::result::Result<T, DHTError>;
+191
View File
@@ -0,0 +1,191 @@
use crate::addr::is_valid_node_addr;
use crate::node_id::TransactionId;
use crate::protocol::DhtResponse;
use crate::types::{NetMode, NodeTuple};
use bytes::BytesMut;
#[cfg(feature = "metrics")]
use metrics::{counter, histogram};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use tokio::net::UdpSocket;
pub(crate) fn for_each_response_node(
response: &DhtResponse,
netmode: NetMode,
mut visit: impl FnMut(NodeTuple),
) -> usize {
let mut count = 0;
if netmode != NetMode::Ipv6Only
&& let Some(nodes) = response.nodes.as_deref()
&& nodes.len() % 26 == 0
{
for chunk in nodes.chunks_exact(26) {
let id: [u8; 20] = chunk[..20].try_into().expect("compact v4 id is 20 bytes");
let ip = Ipv4Addr::new(chunk[20], chunk[21], chunk[22], chunk[23]);
let port = u16::from_be_bytes([chunk[24], chunk[25]]);
let addr = SocketAddr::new(IpAddr::V4(ip), port);
if is_valid_node_addr(&addr) {
visit(NodeTuple { id, addr });
count += 1;
}
}
}
if netmode != NetMode::Ipv4Only
&& let Some(nodes) = response.nodes6.as_deref()
&& nodes.len() % 38 == 0
{
for chunk in nodes.chunks_exact(38) {
let id: [u8; 20] = chunk[..20].try_into().expect("compact v6 id is 20 bytes");
let ip_bytes: [u8; 16] = chunk[20..36]
.try_into()
.expect("compact v6 address is 16 bytes");
let port = u16::from_be_bytes([chunk[36], chunk[37]]);
let addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip_bytes)), port);
if is_valid_node_addr(&addr) {
visit(NodeTuple { id, addr });
count += 1;
}
}
}
count
}
pub(crate) fn encode_find_node_query(
buffer: &mut BytesMut,
tid: &TransactionId,
target: &[u8; 20],
sender_id: &[u8; 20],
) {
buffer.clear();
buffer.reserve(112);
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:q9:find_node1:t8:");
buffer.extend_from_slice(tid);
buffer.extend_from_slice(b"1:y1:qe");
}
pub(crate) fn encode_response(
buffer: &mut BytesMut,
tid: &[u8],
node_id: &[u8; 20],
token: &[u8; 8],
nodes: &[NodeTuple],
ipv6: bool,
) {
buffer.clear();
buffer.reserve(384);
buffer.extend_from_slice(b"d1:rd2:id20:");
buffer.extend_from_slice(node_id);
let compact_len = if ipv6 {
nodes.iter().filter(|node| node.addr.is_ipv6()).count() * 38
} else {
nodes.iter().filter(|node| node.addr.is_ipv4()).count() * 26
};
if compact_len > 0 {
if ipv6 {
buffer.extend_from_slice(b"6:nodes6");
} else {
buffer.extend_from_slice(b"5:nodes");
}
push_usize(buffer, compact_len);
buffer.extend_from_slice(b":");
for node in nodes {
match node.addr.ip() {
IpAddr::V4(ip) if !ipv6 => {
buffer.extend_from_slice(&node.id);
buffer.extend_from_slice(&ip.octets());
buffer.extend_from_slice(&node.addr.port().to_be_bytes());
}
IpAddr::V6(ip) if ipv6 => {
buffer.extend_from_slice(&node.id);
buffer.extend_from_slice(&ip.octets());
buffer.extend_from_slice(&node.addr.port().to_be_bytes());
}
_ => {}
}
}
}
buffer.extend_from_slice(b"5:token8:");
buffer.extend_from_slice(token);
buffer.extend_from_slice(b"e1:t");
push_usize(buffer, tid.len());
buffer.extend_from_slice(b":");
buffer.extend_from_slice(tid);
buffer.extend_from_slice(b"1:y1:re");
}
fn push_usize(buffer: &mut BytesMut, mut value: usize) {
let mut digits = [0u8; 20];
let mut cursor = digits.len();
loop {
cursor -= 1;
digits[cursor] = b'0' + (value % 10) as u8;
value /= 10;
if value == 0 {
break;
}
}
buffer.extend_from_slice(&digits[cursor..]);
}
pub(crate) async fn send_find_node_query(
addr: &SocketAddr,
tid: &TransactionId,
target: &[u8; 20],
sender_id: &[u8; 20],
socket: &Arc<UdpSocket>,
buffer: &mut BytesMut,
) -> bool {
encode_find_node_query(buffer, tid, target, sender_id);
match socket.send_to(buffer, addr).await {
Ok(len) => {
#[cfg(feature = "metrics")]
{
counter!("dht_udp_bytes_sent_total").increment(len as u64);
counter!("dht_udp_packets_sent_total", "type" => "query").increment(1);
histogram!("dht_udp_query_size_bytes").record(len as f64);
}
#[cfg(not(feature = "metrics"))]
let _ = len;
true
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::DhtMessage;
#[test]
fn manual_find_node_encoding_round_trips() {
let mut buffer = BytesMut::new();
let tid = [1; 8];
let target = [2; 20];
let sender = [3; 20];
encode_find_node_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("find_node"));
assert_eq!(message.a.unwrap().target.unwrap().as_ref(), &target);
}
#[test]
fn manual_response_encoding_round_trips() {
let mut buffer = BytesMut::new();
let nodes = [NodeTuple {
id: [4; 20],
addr: "8.8.8.8:6881".parse().unwrap(),
}];
encode_response(&mut buffer, &[1, 2], &[2; 20], &[3; 8], &nodes, false);
let message: DhtMessage = serde_bencode::from_bytes(&buffer).unwrap();
let response = message.r.unwrap();
assert_eq!(response.nodes.unwrap().len(), 26);
}
}
+43 -6
View File
@@ -1,24 +1,61 @@
//! High-throughput BitTorrent DHT crawler with bounded crawl and Metadata pipelines.
//!
//! [`DHTServer`] is the primary entry point. Configure it with [`DHTOptions`], register
//! callbacks, then await [`DHTServer::start`] until another task calls [`DHTServer::shutdown`].
//! Runtime counters are available through [`DHTServer::runtime_stats`] without enabling any
//! exporter. See the repository README for scheduling, backpressure and migration details.
mod addr;
mod bootstrap;
mod budget;
mod crawl_config;
mod crawl_engine;
mod error;
mod krpc;
/// BEP-9 Metadata fetch support.
pub mod metadata;
mod node_id;
mod node_pool;
/// Serializable BEP-5 KRPC wire types.
pub mod protocol;
mod routing_snapshot;
mod runtime_stats;
/// Bounded, deduplicating Metadata scheduler.
pub mod scheduler;
mod server;
mod sharded;
/// Public configuration, callback payload and network types.
pub mod types;
mod udp_buffer;
mod udp_ingress;
pub use error::{DHTError, Result};
pub use scheduler::MetadataScheduler;
pub use runtime_stats::{
DhtObservabilitySnapshot, DhtRuntimeSnapshot, DhtRuntimeStats, FixedHistogramSnapshot,
};
pub use scheduler::{MetadataScheduler, MetadataSchedulerCallbacks, MetadataSchedulerLimits};
pub use server::{DHTServer, HashDiscovered};
pub use sharded::{NodeTuple, ShardedNodeQueue};
pub use types::{DHTOptions, FileInfo, NetMode, TorrentInfo};
pub use types::{
BootstrapOptions, CrawlOptions, DHTOptions, FileInfo, MetadataFetchCompletion,
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PoolOptions,
RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
};
/// Common server, configuration and callback payload imports.
pub mod prelude {
pub use crate::error::{DHTError, Result};
pub use crate::scheduler::MetadataScheduler;
pub use crate::runtime_stats::{DhtRuntimeSnapshot, DhtRuntimeStats};
pub use crate::scheduler::{
MetadataScheduler, MetadataSchedulerCallbacks, MetadataSchedulerLimits,
};
pub use crate::server::DHTServer;
pub use crate::types::{DHTOptions, FileInfo, NetMode, TorrentInfo};
pub use crate::types::{
BootstrapOptions, CrawlOptions, DHTOptions, FileInfo, MetadataFetchCompletion,
MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PoolOptions,
RateLimitOptions, SchedulerOptions, TargetOptions, TorrentInfo,
};
}
#[cfg(feature = "jni")]
#[path = "../jni/mod.rs"]
/// JNI entry points used by the bundled Java wrapper.
pub mod jni_bindings;
+553 -178
View File
@@ -1,236 +1,611 @@
use crate::runtime_stats::DhtRuntimeStats;
use crate::types::FileInfo;
use ahash::AHashMap;
use bytes::Bytes;
#[cfg(feature = "metrics")]
use metrics::{counter, histogram};
use metrics::{counter, gauge, histogram};
use rbit::peer::ExtensionMessage;
use rbit::{
ExtensionHandshake, Message, MetadataMessage, MetadataMessageType, PeerConnection, PeerId,
metadata_piece_count,
};
use sha1::{Digest, Sha1};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
use std::net::SocketAddr;
use std::time::Duration;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::time::timeout;
#[derive(Clone)]
pub struct RbitFetcher {
timeout: Duration,
pub(crate) type FetchedMetadata = (String, u64, Vec<FileInfo>, u64);
pub(crate) enum MetadataFetchOutcome {
Fetched(FetchedMetadata),
Failed,
SkippedCached,
}
impl RbitFetcher {
pub fn new(timeout_secs: u64) -> Self {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MetadataFetchFailure {
Connect,
NoExtension,
Send,
SizeLimit,
Sha1,
Parse,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PeerFailureReason {
Timeout,
ConnectFailed,
}
#[cfg(feature = "metrics")]
impl PeerFailureReason {
fn as_str(self) -> &'static str {
match self {
Self::Timeout => "timeout",
Self::ConnectFailed => "connect_failed",
}
}
}
#[derive(Debug, Clone, Copy)]
struct PeerFailureEntry {
expires_at: Instant,
reason: PeerFailureReason,
}
#[derive(Default)]
struct PeerFailureCacheInner {
entries: AHashMap<SocketAddr, PeerFailureEntry>,
expiry: VecDeque<(Instant, SocketAddr)>,
}
struct PeerFailureCache {
inner: Mutex<PeerFailureCacheInner>,
capacity: usize,
ttl: Duration,
}
impl PeerFailureCache {
fn new(capacity: usize, ttl: Duration) -> Self {
Self {
timeout: Duration::from_secs(if timeout_secs == 0 { 15 } else { timeout_secs }),
inner: Mutex::new(PeerFailureCacheInner {
entries: AHashMap::with_capacity(capacity.min(16_384)),
expiry: VecDeque::with_capacity(capacity.min(16_384)),
}),
capacity,
ttl,
}
}
pub async fn fetch(
fn get(&self, addr: SocketAddr, now: Instant) -> (Option<PeerFailureReason>, usize) {
if self.capacity == 0 || self.ttl.is_zero() {
return (None, 0);
}
let mut inner = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::expire(&mut inner, now);
(
inner.entries.get(&addr).map(|entry| entry.reason),
inner.entries.len(),
)
}
fn insert(&self, addr: SocketAddr, reason: PeerFailureReason, now: Instant) -> usize {
if self.capacity == 0 || self.ttl.is_zero() {
return 0;
}
let mut inner = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::expire(&mut inner, now);
while inner.entries.len() >= self.capacity && !inner.entries.contains_key(&addr) {
let Some((expires_at, oldest_addr)) = inner.expiry.pop_front() else {
break;
};
if inner
.entries
.get(&oldest_addr)
.is_some_and(|entry| entry.expires_at == expires_at)
{
inner.entries.remove(&oldest_addr);
}
}
let expires_at = now + self.ttl;
inner
.entries
.insert(addr, PeerFailureEntry { expires_at, reason });
inner.expiry.push_back((expires_at, addr));
inner.entries.len()
}
fn remove(&self, addr: &SocketAddr, now: Instant) -> usize {
if self.capacity == 0 || self.ttl.is_zero() {
return 0;
}
let mut inner = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Self::expire(&mut inner, now);
inner.entries.remove(addr);
inner.entries.len()
}
fn expire(inner: &mut PeerFailureCacheInner, now: Instant) {
while let Some((expires_at, addr)) = inner.expiry.front().copied() {
if expires_at > now {
break;
}
inner.expiry.pop_front();
if inner
.entries
.get(&addr)
.is_some_and(|entry| entry.expires_at == expires_at)
{
inner.entries.remove(&addr);
}
}
}
}
#[derive(Clone)]
/// BEP-9 Metadata fetcher with an end-to-end timeout and shared Peer failure cache.
pub struct RbitFetcher {
total_timeout: Duration,
runtime_stats: DhtRuntimeStats,
peer_failure_cache: Arc<PeerFailureCache>,
}
impl RbitFetcher {
/// Creates a standalone fetcher with the default failure-cache capacity and TTL.
///
/// [`DHTServer`](crate::DHTServer) normally constructs this component from
/// [`MetadataOptions`](crate::MetadataOptions).
pub fn new(timeout_secs: u64) -> Self {
Self::new_with_runtime_stats(timeout_secs, 200_000, 60, DhtRuntimeStats::default())
}
pub(crate) fn new_with_runtime_stats(
timeout_secs: u64,
peer_failure_cache_capacity: usize,
peer_failure_ttl_secs: u64,
runtime_stats: DhtRuntimeStats,
) -> Self {
Self {
total_timeout: Duration::from_secs(if timeout_secs == 0 { 15 } else { timeout_secs }),
runtime_stats,
peer_failure_cache: Arc::new(PeerFailureCache::new(
peer_failure_cache_capacity,
Duration::from_secs(peer_failure_ttl_secs),
)),
}
}
/// Fetch metadata from one peer under a single end-to-end deadline.
///
/// 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.
pub(crate) async fn fetch(
&self,
info_hash: &[u8; 20],
peer_addr: SocketAddr,
) -> Option<(String, u64, Vec<FileInfo>, u64)> {
) -> MetadataFetchOutcome {
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 {
self.runtime_stats.metadata_peer_failure_cache_hit();
match reason {
PeerFailureReason::Timeout => self.runtime_stats.peer_cache_hit_timeout(),
PeerFailureReason::ConnectFailed => self.runtime_stats.peer_cache_hit_connect(),
}
#[cfg(feature = "metrics")]
counter!("dht_metadata_peer_failure_cache_hits_total", "reason" => reason.as_str())
.increment(1);
#[cfg(not(feature = "metrics"))]
let _ = reason;
return MetadataFetchOutcome::SkippedCached;
}
self.runtime_stats.metadata_peer_attempt();
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_attempts_total").increment(1);
let peer_id = PeerId::generate();
let mut conn = match timeout(
Duration::from_secs(3),
PeerConnection::connect(peer_addr, *info_hash, *peer_id.as_bytes()),
)
.await
{
Ok(Ok(c)) => {
counter!("dht_metadata_fetch_attempts_total").increment(1);
counter!("dht_metadata_peer_attempts_total").increment(1);
}
let started = Instant::now();
let result = timeout(
self.total_timeout,
self.fetch_with_peer(info_hash, peer_addr),
)
.await;
#[cfg(feature = "metrics")]
histogram!("dht_metadata_fetch_duration_seconds").record(started.elapsed().as_secs_f64());
self.runtime_stats.observe_metadata_fetch_duration(
started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
);
match result {
Ok(Ok(metadata)) => {
let cache_entries = self.peer_failure_cache.remove(&peer_addr, Instant::now());
self.set_peer_failure_cache_entries(cache_entries);
self.runtime_stats.metadata_peer_succeeded();
#[cfg(feature = "metrics")]
{
counter!("dht_metadata_fetch_success_total").increment(1);
counter!("dht_metadata_fetch_result_total", "result" => "success").increment(1);
}
MetadataFetchOutcome::Fetched(metadata)
}
Ok(Err(reason)) => {
self.runtime_stats.metadata_peer_failed();
match reason {
MetadataFetchFailure::Connect => self.runtime_stats.metadata_failure_connect(),
MetadataFetchFailure::NoExtension => {
self.runtime_stats.metadata_failure_no_extension()
}
MetadataFetchFailure::Send => self.runtime_stats.metadata_failure_send(),
MetadataFetchFailure::SizeLimit => {
self.runtime_stats.metadata_failure_size_limit()
}
MetadataFetchFailure::Sha1 => self.runtime_stats.metadata_failure_sha1(),
MetadataFetchFailure::Parse => self.runtime_stats.metadata_failure_parse(),
MetadataFetchFailure::Other => self.runtime_stats.metadata_failure_other(),
}
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_result_total", "result" => "failed").increment(1);
MetadataFetchOutcome::Failed
}
Err(_) => {
self.record_peer_failure(peer_addr, PeerFailureReason::Timeout);
self.runtime_stats.metadata_peer_failed();
self.runtime_stats.metadata_peer_timeout();
self.runtime_stats.metadata_failure_timeout();
#[cfg(feature = "metrics")]
{
counter!("dht_metadata_fetch_fail_total", "reason" => "timeout").increment(1);
counter!("dht_metadata_fetch_result_total", "result" => "timeout").increment(1);
}
MetadataFetchOutcome::Failed
}
}
}
fn record_peer_failure(&self, peer_addr: SocketAddr, reason: PeerFailureReason) {
let cache_entries = self
.peer_failure_cache
.insert(peer_addr, reason, Instant::now());
self.set_peer_failure_cache_entries(cache_entries);
#[cfg(feature = "metrics")]
counter!("dht_metadata_peer_failure_cache_inserts_total", "reason" => reason.as_str())
.increment(1);
}
fn set_peer_failure_cache_entries(&self, count: usize) {
self.runtime_stats
.set_metadata_peer_failure_cache_entries(count);
#[cfg(feature = "metrics")]
gauge!("dht_metadata_peer_failure_cache_entries").set(count as f64);
}
async fn fetch_with_peer(
&self,
info_hash: &[u8; 20],
peer_addr: SocketAddr,
) -> Result<FetchedMetadata, MetadataFetchFailure> {
let peer_id = PeerId::generate();
let mut conn = match PeerConnection::connect(peer_addr, *info_hash, *peer_id.as_bytes())
.await
{
Ok(conn) => {
#[cfg(feature = "metrics")]
counter!("dht_metadata_connection_result_total", "result" => "success")
.increment(1);
c
}
Ok(Err(_)) => {
#[cfg(feature = "metrics")]
counter!("dht_metadata_connection_result_total", "result" => "failed").increment(1);
return None;
conn
}
Err(_) => {
self.record_peer_failure(peer_addr, PeerFailureReason::ConnectFailed);
self.runtime_stats.metadata_connect_failed();
#[cfg(feature = "metrics")]
counter!("dht_metadata_connection_result_total", "result" => "timeout")
.increment(1);
return None;
counter!("dht_metadata_connection_result_total", "result" => "failed").increment(1);
return Err(MetadataFetchFailure::Connect);
}
};
if !conn.supports_extension {
self.runtime_stats.metadata_no_extension();
#[cfg(feature = "metrics")]
counter!("dht_metadata_handshake_result_total", "result" => "no_extension_support")
.increment(1);
return None;
return Err(MetadataFetchFailure::NoExtension);
}
let my_ut_metadata_id = 1;
let handshake = ExtensionHandshake::with_extensions(&[("ut_metadata", my_ut_metadata_id)]);
if let Ok(handshake_bytes) = handshake.encode() {
let _ = conn
.send(Message::Extended {
id: 0,
payload: handshake_bytes,
})
.await;
} else {
return None;
let handshake_bytes = handshake.encode().map_err(|_| MetadataFetchFailure::Send)?;
if conn
.send(Message::Extended {
id: 0,
payload: handshake_bytes,
})
.await
.is_err()
{
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "send_error").increment(1);
return Err(MetadataFetchFailure::Send);
}
let mut metadata_size = 0;
let mut remote_ut_metadata_id = 0;
let mut pieces: BTreeMap<u32, Bytes> = BTreeMap::new();
let mut total_received = 0usize;
let mut request_sent = false;
let result = timeout(self.timeout, async {
loop {
let msg = conn.receive().await.ok()?;
if let Message::Extended { id, payload } = msg {
if id == 0 {
if let Ok(ExtensionMessage::Handshake(remote_hs)) = ExtensionMessage::decode(id, &payload) {
if let Some(size) = remote_hs.metadata_size {
metadata_size = size as u32;
}
if let Some(ext_id) = remote_hs.get_extension_id("ut_metadata") {
remote_ut_metadata_id = ext_id;
}
}
if metadata_size > 0 && remote_ut_metadata_id > 0 && !request_sent {
if metadata_size > 10 * 1024 * 1024 {
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "size_limit").increment(1);
return None;
}
let info_bytes = loop {
let msg = conn
.receive()
.await
.map_err(|_| MetadataFetchFailure::Other)?;
let Message::Extended { id, payload } = msg else {
continue;
};
let count = metadata_piece_count(metadata_size as usize);
for i in 0..count {
let req = MetadataMessage::request(i as u32);
if let Ok(encoded) = req.encode() {
let _ = conn.send(Message::Extended { id: remote_ut_metadata_id, payload: encoded }).await;
}
}
request_sent = true;
}
} else if id == my_ut_metadata_id {
if let Ok(meta_msg) = MetadataMessage::decode(&payload)
&& meta_msg.msg_type == MetadataMessageType::Data
&& let Some(data) = meta_msg.data {
#[cfg(feature = "metrics")]
counter!("dht_metadata_bytes_downloaded_total").increment(data.len() as u64);
pieces.insert(meta_msg.piece, data);
}
if metadata_size > 0 {
let total_received: usize = pieces.values().map(|p| p.len()).sum();
if total_received >= metadata_size as usize {
let mut full_data = Vec::with_capacity(metadata_size as usize);
let count = metadata_piece_count(metadata_size as usize);
let mut success = true;
for i in 0..count {
if let Some(p) = pieces.get(&(i as u32)) {
full_data.extend_from_slice(p);
} else {
success = false; break;
}
}
if success {
let info_hash_copy = *info_hash;
let validated = tokio::task::spawn_blocking(move || {
let mut hasher = Sha1::new();
hasher.update(&full_data);
let digest: [u8; 20] = hasher.finalize().into();
if digest == info_hash_copy {
Some(full_data)
} else {
None
}
}).await.unwrap_or(None);
if validated.is_some() {
#[cfg(feature = "metrics")]
counter!("dht_metadata_handshake_result_total", "result" => "success").increment(1);
return validated;
}
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "sha1_mismatch").increment(1);
return None;
}
}
}
}
}
}
}).await;
match result {
Ok(Some(info_bytes)) => {
if let Ok(value) = rbit::decode(&info_bytes)
&& let Some(dict) = value.as_dict()
if id == 0 {
if let Ok(ExtensionMessage::Handshake(remote_hs)) =
ExtensionMessage::decode(id, &payload)
{
let name = dict
.get(&b"name"[..])
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let piece_length = dict
.get(&b"piece length"[..])
.and_then(|v| v.as_integer())
.unwrap_or(0) as u64;
let mut total_size = 0;
let mut file_list = Vec::new();
if let Some(files) = dict.get(&b"files"[..]).and_then(|v| v.as_list()) {
for file in files {
if let Some(f_dict) = file.as_dict()
&& let Some(len) =
f_dict.get(&b"length"[..]).and_then(|v| v.as_integer())
{
let len = len as u64;
total_size += len;
let mut path_parts = Vec::new();
if let Some(path_list) =
f_dict.get(&b"path"[..]).and_then(|v| v.as_list())
{
for p in path_list {
if let Some(p_str) = p.as_str() {
path_parts.push(p_str);
}
}
}
file_list.push(FileInfo {
path: path_parts.join("/"),
size: len,
});
}
}
} else if let Some(len) = dict.get(&b"length"[..]).and_then(|v| v.as_integer())
{
total_size = len as u64;
file_list.push(FileInfo {
path: name.clone(),
size: total_size,
});
if let Some(size) = remote_hs.metadata_size {
metadata_size = size as u32;
}
if total_size > 0 {
#[cfg(feature = "metrics")]
{
counter!("dht_metadata_fetch_success_total").increment(1);
histogram!("dht_metadata_size_bytes").record(total_size as f64);
}
return Some((name, total_size, file_list, piece_length));
if let Some(ext_id) = remote_hs.get_extension_id("ut_metadata") {
remote_ut_metadata_id = ext_id;
}
}
if metadata_size > 0 && remote_ut_metadata_id > 0 && !request_sent {
if metadata_size > 10 * 1024 * 1024 {
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "size_limit")
.increment(1);
return Err(MetadataFetchFailure::SizeLimit);
}
let count = metadata_piece_count(metadata_size as usize);
for piece in 0..count {
let encoded = MetadataMessage::request(piece as u32)
.encode()
.map_err(|_| MetadataFetchFailure::Send)?;
if conn
.send(Message::Extended {
id: remote_ut_metadata_id,
payload: encoded,
})
.await
.is_err()
{
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "send_error")
.increment(1);
return Err(MetadataFetchFailure::Send);
}
}
request_sent = true;
}
continue;
}
if id != my_ut_metadata_id {
continue;
}
let Ok(meta_msg) = MetadataMessage::decode(&payload) else {
continue;
};
if meta_msg.msg_type != MetadataMessageType::Data {
continue;
}
let Some(data) = meta_msg.data else {
continue;
};
#[cfg(feature = "metrics")]
counter!("dht_metadata_bytes_downloaded_total").increment(data.len() as u64);
self.runtime_stats.metadata_bytes_downloaded(data.len());
let data_len = data.len();
if let Some(previous) = pieces.insert(meta_msg.piece, data) {
total_received = total_received.saturating_sub(previous.len());
}
total_received = total_received.saturating_add(data_len);
if metadata_size == 0 || total_received < metadata_size as usize {
continue;
}
let count = metadata_piece_count(metadata_size as usize);
let mut full_data = Vec::with_capacity(metadata_size as usize);
for piece in 0..count {
let data = pieces
.get(&(piece as u32))
.ok_or(MetadataFetchFailure::Other)?;
full_data.extend_from_slice(data);
}
let info_hash_copy = *info_hash;
let validated = tokio::task::spawn_blocking(move || {
let mut hasher = Sha1::new();
hasher.update(&full_data);
let digest: [u8; 20] = hasher.finalize().into();
(digest == info_hash_copy).then_some(full_data)
})
.await
.ok()
.flatten();
match validated {
Some(data) => {
#[cfg(feature = "metrics")]
counter!("dht_metadata_handshake_result_total", "result" => "success")
.increment(1);
break data;
}
None => {
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "sha1_mismatch")
.increment(1);
return Err(MetadataFetchFailure::Sha1);
}
}
};
self.runtime_stats.observe_metadata_size(info_bytes.len());
match parse_metadata(&info_bytes) {
Some(metadata) => {
#[cfg(feature = "metrics")]
histogram!("dht_metadata_size_bytes").record(info_bytes.len() as f64);
Ok(metadata)
}
None => {
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "parse_error").increment(1);
None
}
_ => {
#[cfg(feature = "metrics")]
counter!("dht_metadata_fetch_fail_total", "reason" => "timeout").increment(1);
None
Err(MetadataFetchFailure::Parse)
}
}
}
}
fn parse_metadata(info_bytes: &[u8]) -> Option<FetchedMetadata> {
let value = rbit::decode(info_bytes).ok()?;
let dict = value.as_dict()?;
let name = dict
.get(&b"name"[..])
.and_then(|value| value.as_str())
.unwrap_or("Unknown")
.to_string();
let piece_length = dict
.get(&b"piece length"[..])
.and_then(|value| value.as_integer())
.unwrap_or(0) as u64;
let mut total_size = 0;
let mut file_list = Vec::new();
if let Some(files) = dict.get(&b"files"[..]).and_then(|value| value.as_list()) {
for file in files {
let Some(file_dict) = file.as_dict() else {
continue;
};
let Some(length) = file_dict
.get(&b"length"[..])
.and_then(|value| value.as_integer())
else {
continue;
};
let length = length as u64;
total_size += length;
let path = file_dict
.get(&b"path"[..])
.and_then(|value| value.as_list())
.map(|parts| {
parts
.iter()
.filter_map(|part| part.as_str())
.collect::<Vec<_>>()
.join("/")
})
.unwrap_or_default();
file_list.push(FileInfo { path, size: length });
}
} else if let Some(length) = dict
.get(&b"length"[..])
.and_then(|value| value.as_integer())
{
total_size = length as u64;
file_list.push(FileInfo {
path: name.clone(),
size: total_size,
});
}
(total_size > 0).then_some((name, total_size, file_list, piece_length))
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
#[test]
fn peer_failure_cache_is_socket_specific_and_expires() {
let start = Instant::now();
let cache = PeerFailureCache::new(10, Duration::from_secs(60));
let first: SocketAddr = "127.0.0.1:1000".parse().unwrap();
let same_ip_other_port: SocketAddr = "127.0.0.1:1001".parse().unwrap();
assert_eq!(cache.insert(first, PeerFailureReason::Timeout, start), 1);
assert_eq!(cache.get(first, start).0, Some(PeerFailureReason::Timeout));
assert_eq!(cache.get(same_ip_other_port, start).0, None);
assert_eq!(cache.get(first, start + Duration::from_secs(61)), (None, 0));
}
#[test]
fn peer_failure_cache_evicts_oldest_at_capacity() {
let start = Instant::now();
let cache = PeerFailureCache::new(1, Duration::from_secs(60));
let first: SocketAddr = "127.0.0.1:1000".parse().unwrap();
let second: SocketAddr = "127.0.0.1:1001".parse().unwrap();
cache.insert(first, PeerFailureReason::Timeout, start);
cache.insert(second, PeerFailureReason::ConnectFailed, start);
assert_eq!(cache.get(first, start).0, None);
assert_eq!(
cache.get(second, start).0,
Some(PeerFailureReason::ConnectFailed)
);
}
#[tokio::test]
async fn total_timeout_covers_peer_handshake() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accept_task = tokio::spawn(async move {
let (_stream, _) = listener.accept().await.unwrap();
std::future::pending::<()>().await;
});
let stats = DhtRuntimeStats::default();
let fetcher = RbitFetcher::new_with_runtime_stats(1, 10, 60, stats.clone());
let started = Instant::now();
assert!(matches!(
fetcher.fetch(&[7; 20], addr).await,
MetadataFetchOutcome::Failed
));
assert!(started.elapsed() < Duration::from_secs(2));
let cached_started = Instant::now();
assert!(matches!(
fetcher.fetch(&[8; 20], addr).await,
MetadataFetchOutcome::SkippedCached
));
assert!(cached_started.elapsed() < Duration::from_millis(100));
let snapshot = stats.snapshot();
assert_eq!(snapshot.metadata_peer_attempts, 1);
assert_eq!(snapshot.metadata_peer_failed, 1);
assert_eq!(snapshot.metadata_peer_timeouts, 1);
assert_eq!(snapshot.metadata_peer_failure_cache_hits, 1);
assert_eq!(snapshot.metadata_peer_failure_cache_entries, 1);
accept_task.abort();
}
}
+82
View File
@@ -0,0 +1,82 @@
use rand::Rng;
pub(crate) type TransactionId = [u8; 8];
pub(crate) fn transaction_id_from_bytes(bytes: &[u8]) -> Option<TransactionId> {
if bytes.len() != 8 {
return None;
}
let mut tid = [0u8; 8];
tid.copy_from_slice(bytes);
Some(tid)
}
pub(crate) fn random_node_id() -> [u8; 20] {
let mut id = [0u8; 20];
rand::thread_rng().fill(&mut id);
id
}
pub(crate) fn neighbor_node_id(remote_id: &[u8], local_id: &[u8]) -> Vec<u8> {
let mut id = Vec::with_capacity(20);
let prefix_len = remote_id.len().min(6);
id.extend_from_slice(&remote_id[..prefix_len]);
if local_id.len() > prefix_len {
id.extend_from_slice(&local_id[prefix_len..]);
}
while id.len() < 20 {
id.push(rand::random());
}
id.truncate(20);
id
}
pub(crate) fn bucket_index(id: &[u8], local_id: &[u8; 20]) -> usize {
for bit in 0..160 {
let byte = bit / 8;
if byte >= id.len() {
break;
}
let mask = 1 << (7 - (bit % 8));
if (id[byte] ^ local_id[byte]) & mask != 0 {
return bit;
}
}
159
}
pub(crate) fn target_for_bucket(local_id: &[u8; 20], bucket: usize) -> [u8; 20] {
let mut id = *local_id;
let bucket = bucket.min(159);
let byte = bucket / 8;
let bit = 7 - (bucket % 8);
id[byte] ^= 1 << bit;
for item in id.iter_mut().skip(byte + 1) {
*item = rand::random();
}
id
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transaction_ids_are_eight_bytes() {
let first = 1u64.to_be_bytes();
assert_eq!(transaction_id_from_bytes(&first), Some(first));
assert!(transaction_id_from_bytes(&[1, 2]).is_none());
}
#[test]
fn neighbor_id_keeps_remote_prefix_and_local_suffix() {
let remote = [1u8; 20];
let local = [2u8; 20];
let id = neighbor_node_id(&remote, &local);
assert_eq!(&id[..6], &[1u8; 6]);
assert_eq!(&id[6..], &[2u8; 14]);
}
}
+390
View File
@@ -0,0 +1,390 @@
use crate::addr::is_valid_node_addr;
use crate::budget::RateBucket;
use crate::types::NodeTuple;
use ahash::{AHashMap, AHashSet};
use std::collections::VecDeque;
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AdmissionOutcome {
Admitted,
Replaced,
Duplicate,
RateLimited,
Invalid,
}
#[derive(Debug, Clone, Copy)]
struct QueuedNode {
node: NodeTuple,
#[cfg_attr(not(feature = "metrics"), allow(dead_code))]
queued_at: Instant,
}
pub(crate) struct NodePool {
queue: VecDeque<QueuedNode>,
queued: AHashSet<SocketAddr>,
recent: AHashMap<SocketAddr, Instant>,
recent_expiry: VecDeque<(Instant, SocketAddr)>,
replacement_budget: RateBucket,
recent_ttl: Duration,
capacity: usize,
warmed: bool,
}
impl NodePool {
pub(crate) fn new(
capacity: usize,
replacements_per_minute: u32,
recent_ttl: Duration,
now: Instant,
) -> Self {
let capacity = capacity.max(1);
let replacement_burst = replacements_per_minute.div_ceil(60).max(1);
Self {
queue: VecDeque::with_capacity(capacity),
queued: AHashSet::with_capacity(capacity),
recent: AHashMap::with_capacity(capacity),
recent_expiry: VecDeque::with_capacity(capacity),
replacement_budget: RateBucket::per_minute(
replacements_per_minute,
replacement_burst,
true,
now,
),
recent_ttl,
capacity,
warmed: false,
}
}
pub(crate) fn admit(&mut self, node: NodeTuple, now: Instant) -> AdmissionOutcome {
if !is_valid_node_addr(&node.addr) {
return AdmissionOutcome::Invalid;
}
self.expire_recent(now);
if self.queued.contains(&node.addr) || self.recent.contains_key(&node.addr) {
return AdmissionOutcome::Duplicate;
}
if self.warmed && !self.replacement_budget.try_take_one(now) {
return AdmissionOutcome::RateLimited;
}
let replaced = if self.queue.len() >= self.capacity {
self.pop_front_internal().is_some()
} else {
false
};
self.queued.insert(node.addr);
self.queue.push_back(QueuedNode {
node,
queued_at: now,
});
if self.queue.len() >= self.capacity {
self.warmed = true;
}
if replaced {
AdmissionOutcome::Replaced
} else {
AdmissionOutcome::Admitted
}
}
pub(crate) fn front(&self) -> Option<NodeTuple> {
self.queue.front().map(|entry| entry.node)
}
/// Move the FIFO head behind the remaining queued nodes without marking
/// it as probed. The address stays in `queued` and is not added to `recent`.
pub(crate) fn rotate_front_to_back(&mut self) -> bool {
if self.queue.len() <= 1 {
return false;
}
self.queue.rotate_left(1);
true
}
pub(crate) fn take_front_for_probe(&mut self, now: Instant) -> Option<NodeTuple> {
let entry = self.pop_front_internal()?;
let expires_at = now + self.recent_ttl;
self.recent.insert(entry.node.addr, expires_at);
self.recent_expiry.push_back((expires_at, entry.node.addr));
Some(entry.node)
}
pub(crate) fn restore_front(&mut self, node: NodeTuple, queued_at: Instant) {
self.recent.remove(&node.addr);
self.queued.insert(node.addr);
self.queue.push_front(QueuedNode { node, queued_at });
}
pub(crate) fn contains_recent(&mut self, addr: &SocketAddr, now: Instant) -> bool {
self.expire_recent(now);
self.recent.contains_key(addr)
}
pub(crate) fn record_probe(&mut self, addr: SocketAddr, now: Instant) {
let expires_at = now + self.recent_ttl;
self.recent.insert(addr, expires_at);
self.recent_expiry.push_back((expires_at, addr));
}
pub(crate) fn len(&self) -> usize {
self.queue.len()
}
#[cfg_attr(not(feature = "metrics"), allow(dead_code))]
pub(crate) fn oldest_age(&self, now: Instant) -> Duration {
self.queue
.front()
.and_then(|entry| now.checked_duration_since(entry.queued_at))
.unwrap_or_default()
}
#[cfg(test)]
fn is_warmed(&self) -> bool {
self.warmed
}
fn pop_front_internal(&mut self) -> Option<QueuedNode> {
let entry = self.queue.pop_front()?;
self.queued.remove(&entry.node.addr);
Some(entry)
}
fn expire_recent(&mut self, now: Instant) {
while let Some((expires_at, addr)) = self.recent_expiry.front().copied() {
if expires_at > now {
break;
}
self.recent_expiry.pop_front();
if self.recent.get(&addr).copied() == Some(expires_at) {
self.recent.remove(&addr);
}
}
}
}
#[derive(Debug, Clone, Copy)]
struct ResponsiveEntry {
node: NodeTuple,
expires_at: Instant,
}
/// Fixed-size responsive-node ring. The crawl actor is the only writer.
pub(crate) struct ResponsiveReservoir {
slots: Vec<Option<ResponsiveEntry>>,
index: AHashMap<SocketAddr, usize>,
write_cursor: usize,
revisit_cursor: usize,
ttl: Duration,
}
impl ResponsiveReservoir {
pub(crate) fn new(capacity: usize, ttl: Duration) -> Self {
let capacity = capacity.max(1);
Self {
slots: vec![None; capacity],
index: AHashMap::with_capacity(capacity),
write_cursor: 0,
revisit_cursor: 0,
ttl,
}
}
pub(crate) fn record(&mut self, node: NodeTuple, now: Instant) {
let entry = ResponsiveEntry {
node,
expires_at: now + self.ttl,
};
if let Some(slot) = self.index.get(&node.addr).copied() {
self.slots[slot] = Some(entry);
return;
}
let slot = self.write_cursor;
if let Some(old) = self.slots[slot]
&& self.index.get(&old.node.addr).copied() == Some(slot)
{
self.index.remove(&old.node.addr);
}
self.slots[slot] = Some(entry);
self.index.insert(node.addr, slot);
self.write_cursor = (self.write_cursor + 1) % self.slots.len();
}
pub(crate) fn next_revisit(&mut self, now: Instant) -> Option<NodeTuple> {
for _ in 0..self.slots.len() {
let slot = self.revisit_cursor;
self.revisit_cursor = (self.revisit_cursor + 1) % self.slots.len();
let Some(entry) = self.slots[slot] else {
continue;
};
if entry.expires_at <= now {
if self.index.get(&entry.node.addr).copied() == Some(slot) {
self.index.remove(&entry.node.addr);
}
self.slots[slot] = None;
continue;
}
return Some(entry.node);
}
None
}
pub(crate) fn snapshot(&self, limit: usize, now: Instant) -> Vec<NodeTuple> {
self.slots
.iter()
.filter_map(|entry| {
entry
.filter(|entry| entry.expires_at > now)
.map(|entry| entry.node)
})
.take(limit)
.collect()
}
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub(crate) enum SubnetKey {
V4([u8; 3]),
V6([u8; 8]),
}
impl SubnetKey {
pub(crate) fn from_addr(addr: &SocketAddr) -> Self {
match addr.ip() {
IpAddr::V4(ip) => {
let octets = ip.octets();
Self::V4([octets[0], octets[1], octets[2]])
}
IpAddr::V6(ip) => {
let octets = ip.octets();
Self::V6(octets[..8].try_into().expect("IPv6 prefix has eight bytes"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr};
fn node(id: u8, addr: &str) -> NodeTuple {
NodeTuple {
id: [id; 20],
addr: addr.parse().unwrap(),
}
}
#[test]
fn strict_fifo_replaces_oldest_after_warmup() {
let start = Instant::now();
let mut pool = NodePool::new(2, 600, Duration::from_secs(60), start);
let first = node(1, "8.8.8.8:1");
let second = node(2, "1.1.1.1:2");
let third = node(3, "9.9.9.9:3");
assert_eq!(pool.admit(first, start), AdmissionOutcome::Admitted);
assert_eq!(pool.admit(second, start), AdmissionOutcome::Admitted);
assert!(pool.is_warmed());
assert_eq!(pool.admit(third, start), AdmissionOutcome::Replaced);
assert_eq!(pool.front(), Some(second));
}
#[test]
fn duplicate_does_not_reorder_fifo() {
let start = Instant::now();
let mut pool = NodePool::new(3, 600, Duration::from_secs(60), start);
let first = node(1, "8.8.8.8:1");
let second = node(2, "1.1.1.1:2");
pool.admit(first, start);
pool.admit(second, start);
assert_eq!(pool.admit(first, start), AdmissionOutcome::Duplicate);
assert_eq!(pool.front(), Some(first));
}
#[test]
fn rotating_front_preserves_queued_dedup_and_recent_state() {
let start = Instant::now();
let mut pool = NodePool::new(3, 600, Duration::from_secs(60), start);
let first = node(1, "8.8.8.8:1");
let second = node(2, "1.1.1.1:2");
assert_eq!(pool.admit(first, start), AdmissionOutcome::Admitted);
assert_eq!(pool.admit(second, start), AdmissionOutcome::Admitted);
assert!(pool.rotate_front_to_back());
assert_eq!(pool.front(), Some(second));
assert_eq!(pool.admit(first, start), AdmissionOutcome::Duplicate);
assert!(!pool.contains_recent(&first.addr, start));
}
#[test]
fn warmed_pool_enforces_replacement_rate() {
let start = Instant::now();
let mut pool = NodePool::new(2, 60, Duration::from_secs(60), start);
pool.admit(node(1, "8.8.8.8:1"), start);
pool.admit(node(2, "1.1.1.1:2"), start);
assert_eq!(
pool.admit(node(3, "9.9.9.9:3"), start),
AdmissionOutcome::Replaced
);
assert_eq!(
pool.admit(node(4, "208.67.222.222:4"), start),
AdmissionOutcome::RateLimited
);
assert_eq!(
pool.admit(node(4, "208.67.222.222:4"), start + Duration::from_secs(1)),
AdmissionOutcome::Replaced
);
}
#[test]
fn recent_probe_blocks_readmission_until_expiry() {
let start = Instant::now();
let mut pool = NodePool::new(3, 600, Duration::from_secs(10), start);
let first = node(1, "8.8.8.8:1");
pool.admit(first, start);
assert_eq!(pool.take_front_for_probe(start), Some(first));
assert_eq!(pool.admit(first, start), AdmissionOutcome::Duplicate);
assert_eq!(
pool.admit(first, start + Duration::from_secs(11)),
AdmissionOutcome::Admitted
);
}
#[test]
fn responsive_ring_overwrites_without_growing() {
let start = Instant::now();
let mut reservoir = ResponsiveReservoir::new(2, Duration::from_secs(10));
reservoir.record(node(1, "8.8.8.8:1"), start);
reservoir.record(node(2, "1.1.1.1:2"), start);
reservoir.record(node(3, "9.9.9.9:3"), start);
let snapshot = reservoir.snapshot(10, start);
assert_eq!(snapshot.len(), 2);
assert!(!snapshot.iter().any(|entry| entry.id == [1; 20]));
}
#[test]
#[ignore = "release-only FIFO throughput smoke test"]
fn million_fifo_operations() {
let start = Instant::now();
let mut pool = NodePool::new(100_000, u32::MAX, Duration::from_secs(600), start);
for value in 0..1_000_000u32 {
let octets = value.to_be_bytes();
let node = NodeTuple {
id: [octets[3]; 20],
addr: SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(11, octets[1], octets[2], octets[3])),
(value % 65_534 + 1) as u16,
),
};
let outcome = pool.admit(node, start);
assert!(!matches!(outcome, AdmissionOutcome::RateLimited));
}
assert_eq!(pool.len(), 100_000);
eprintln!("1,000,000 FIFO admissions in {:?}", start.elapsed());
}
}
+20 -3
View File
@@ -1,34 +1,51 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
#[allow(dead_code)]
/// Decoded KRPC envelope.
pub struct DhtMessage {
/// Transaction ID bytes.
pub t: serde_bytes::ByteBuf,
#[allow(dead_code)]
/// Message kind (`q`, `r`, or `e`).
pub y: String,
#[allow(dead_code)]
/// Query method when `y == q`.
pub q: Option<String>,
/// Query arguments.
pub a: Option<DhtArgs>,
/// Response dictionary.
pub r: Option<DhtResponse>,
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
/// Supported BEP-5 query arguments.
pub struct DhtArgs {
/// Sender node ID.
pub id: Option<serde_bytes::ByteBuf>,
/// find_node target ID.
pub target: Option<serde_bytes::ByteBuf>,
/// get_peers/announce InfoHash.
pub info_hash: Option<serde_bytes::ByteBuf>,
/// announce validation token.
pub token: Option<serde_bytes::ByteBuf>,
/// Explicit announced Peer port.
pub port: Option<u16>,
/// Non-zero means use the UDP source port.
pub implied_port: Option<u8>,
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
/// Supported BEP-5 response fields.
pub struct DhtResponse {
#[serde(default)]
#[allow(dead_code)]
/// Responder node ID.
pub id: Option<serde_bytes::ByteBuf>,
#[serde(default)]
/// Compact IPv4 node tuples.
pub nodes: Option<serde_bytes::ByteBuf>,
#[serde(default)]
/// Compact IPv6 node tuples.
pub nodes6: Option<serde_bytes::ByteBuf>,
}
+37
View File
@@ -0,0 +1,37 @@
use crate::types::NodeTuple;
use rand::seq::SliceRandom;
#[derive(Default)]
pub(crate) struct RoutingSnapshot {
v4: Vec<NodeTuple>,
v6: Vec<NodeTuple>,
}
impl RoutingSnapshot {
pub(crate) fn from_nodes(nodes: Vec<NodeTuple>, limit: usize) -> Self {
let mut v4 = Vec::new();
let mut v6 = Vec::new();
for node in nodes.into_iter().take(limit) {
if node.addr.is_ipv6() {
v6.push(node);
} else {
v4.push(node);
}
}
Self { v4, v6 }
}
pub(crate) fn random_nodes(&self, count: usize, filter_ipv6: Option<bool>) -> Vec<NodeTuple> {
let mut rng = rand::thread_rng();
match filter_ipv6 {
Some(true) => self.v6.choose_multiple(&mut rng, count).cloned().collect(),
Some(false) => self.v4.choose_multiple(&mut rng, count).cloned().collect(),
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.choose_multiple(&mut rng, count).cloned().collect()
}
}
}
}
+1063
View File
File diff suppressed because it is too large Load Diff
+858 -180
View File
File diff suppressed because it is too large Load Diff
+612 -671
View File
File diff suppressed because it is too large Load Diff
-291
View File
@@ -1,291 +0,0 @@
use std::collections::{HashSet, VecDeque};
use std::net::SocketAddr;
use std::sync::Mutex;
const QUEUE_SHARD_COUNT: usize = 16;
#[derive(Debug, Clone)]
pub struct NodeTuple {
pub id: Vec<u8>,
pub addr: SocketAddr,
}
struct NodeQueueShard {
queue: VecDeque<NodeTuple>,
index: HashSet<SocketAddr>,
capacity: usize,
}
impl NodeQueueShard {
fn new(capacity: usize) -> Self {
Self {
queue: VecDeque::with_capacity(capacity),
index: HashSet::with_capacity(capacity),
capacity,
}
}
fn push(&mut self, node: NodeTuple) {
if self.index.contains(&node.addr) {
return;
}
if self.queue.len() >= self.capacity
&& let Some(removed) = self.queue.pop_front()
{
self.index.remove(&removed.addr);
}
self.index.insert(node.addr);
self.queue.push_back(node);
}
fn pop_batch(&mut self, count: usize) -> Vec<NodeTuple> {
let actual_count = count.min(self.queue.len());
let mut nodes = Vec::with_capacity(actual_count);
for _ in 0..actual_count {
if let Some(node) = self.queue.pop_front() {
self.index.remove(&node.addr);
nodes.push(node);
}
}
nodes
}
fn len(&self) -> usize {
self.queue.len()
}
fn is_empty(&self) -> bool {
self.queue.is_empty()
}
}
pub struct ShardedNodeQueue {
shards_v4: Vec<Mutex<NodeQueueShard>>,
shards_v6: Vec<Mutex<NodeQueueShard>>,
}
impl ShardedNodeQueue {
pub fn new(total_capacity: usize) -> Self {
#[allow(clippy::manual_div_ceil)]
let capacity_per_shard = (total_capacity + QUEUE_SHARD_COUNT - 1) / QUEUE_SHARD_COUNT;
let shards_v4 = (0..QUEUE_SHARD_COUNT)
.map(|_| Mutex::new(NodeQueueShard::new(capacity_per_shard)))
.collect();
let shards_v6 = (0..QUEUE_SHARD_COUNT)
.map(|_| Mutex::new(NodeQueueShard::new(capacity_per_shard)))
.collect();
Self {
shards_v4,
shards_v6,
}
}
pub fn push(&self, node: NodeTuple) {
let shard_idx = self.addr_to_shard(&node.addr);
if node.addr.is_ipv6() {
let mut shard = self.shards_v6[shard_idx].lock().unwrap_or_else(|e| e.into_inner());
shard.push(node);
} else {
let mut shard = self.shards_v4[shard_idx].lock().unwrap_or_else(|e| e.into_inner());
shard.push(node);
}
}
pub fn pop_batch(&self, count: usize, filter_ipv6: Option<bool>) -> Vec<NodeTuple> {
let mut result = Vec::with_capacity(count);
#[allow(clippy::manual_div_ceil)]
let per_shard = (count + QUEUE_SHARD_COUNT - 1) / QUEUE_SHARD_COUNT;
match filter_ipv6 {
Some(true) => {
for shard in &self.shards_v6 {
if result.len() >= count {
break;
}
let mut s = shard.lock().unwrap_or_else(|e| e.into_inner());
let nodes = s.pop_batch(per_shard);
result.extend(nodes);
}
}
Some(false) => {
for shard in &self.shards_v4 {
if result.len() >= count {
break;
}
let mut s = shard.lock().unwrap_or_else(|e| e.into_inner());
let nodes = s.pop_batch(per_shard);
result.extend(nodes);
}
}
None => {
for i in 0..QUEUE_SHARD_COUNT {
if result.len() >= count {
break;
}
let mut s4 = self.shards_v4[i].lock().unwrap_or_else(|e| e.into_inner());
let nodes4 = s4.pop_batch(per_shard / 2);
result.extend(nodes4);
drop(s4);
if result.len() >= count {
break;
}
let mut s6 = self.shards_v6[i].lock().unwrap_or_else(|e| e.into_inner());
let nodes6 = s6.pop_batch(per_shard / 2);
result.extend(nodes6);
drop(s6);
}
}
}
result
}
pub fn get_random_nodes(&self, count: usize, filter_ipv6: Option<bool>) -> Vec<NodeTuple> {
match filter_ipv6 {
Some(true) => self.get_random_nodes_from_shards(&self.shards_v6, count),
Some(false) => self.get_random_nodes_from_shards(&self.shards_v4, count),
None => {
let count_v4 = count / 2;
let count_v6 = count - count_v4;
let mut result = Vec::with_capacity(count);
result.extend(self.get_random_nodes_from_shards(&self.shards_v4, count_v4));
result.extend(self.get_random_nodes_from_shards(&self.shards_v6, count_v6));
result
}
}
}
fn get_random_nodes_from_shards(
&self,
shards: &[Mutex<NodeQueueShard>],
count: usize,
) -> Vec<NodeTuple> {
use rand::Rng;
let mut rng = rand::thread_rng();
if count <= 16 {
let mut result = Vec::with_capacity(count);
#[allow(clippy::manual_div_ceil)]
let per_shard = (count + QUEUE_SHARD_COUNT - 1) / QUEUE_SHARD_COUNT;
for shard in shards {
if result.len() >= count {
break;
}
let s = shard.lock().unwrap_or_else(|e| e.into_inner());
let shard_len = s.queue.len();
if shard_len == 0 {
continue;
}
let to_take = per_shard.min(shard_len).min(count - result.len());
let mut indices: Vec<usize> = (0..shard_len).collect();
for i in 0..to_take {
let j = rng.gen_range(i..shard_len);
indices.swap(i, j);
}
for &idx in indices.iter().take(to_take) {
if let Some(node) = s.queue.get(idx) {
result.push(node.clone());
}
}
}
result
} else {
let mut result = Vec::with_capacity(count);
let mut seen = 0usize;
for shard in shards {
let s = shard.lock().unwrap_or_else(|e| e.into_inner());
for node in s.queue.iter() {
seen += 1;
if result.len() < count {
result.push(node.clone());
} else {
let j = rng.gen_range(0..seen);
if j < count {
result[j] = node.clone();
}
}
}
}
result
}
}
pub fn len(&self) -> usize {
let len_v4: usize = self
.shards_v4
.iter()
.map(|shard| shard.lock().unwrap_or_else(|e| e.into_inner()).len())
.sum();
let len_v6: usize = self
.shards_v6
.iter()
.map(|shard| shard.lock().unwrap_or_else(|e| e.into_inner()).len())
.sum();
len_v4 + len_v6
}
pub fn is_empty(&self) -> bool {
let empty_v4 = self
.shards_v4
.iter()
.all(|shard| shard.lock().unwrap_or_else(|e| e.into_inner()).is_empty());
let empty_v6 = self
.shards_v6
.iter()
.all(|shard| shard.lock().unwrap_or_else(|e| e.into_inner()).is_empty());
empty_v4 && empty_v6
}
pub fn is_empty_for(&self, filter_ipv6: Option<bool>) -> bool {
match filter_ipv6 {
Some(true) => self
.shards_v6
.iter()
.all(|shard| shard.lock().unwrap_or_else(|e| e.into_inner()).is_empty()),
Some(false) => self
.shards_v4
.iter()
.all(|shard| shard.lock().unwrap_or_else(|e| e.into_inner()).is_empty()),
None => self.is_empty(),
}
}
#[inline]
fn addr_to_shard(&self, addr: &SocketAddr) -> usize {
let hash = match addr.ip() {
std::net::IpAddr::V4(ip) => {
let octets = ip.octets();
(octets[3] as usize) ^ (addr.port() as usize)
}
std::net::IpAddr::V6(ip) => {
let octets = ip.octets();
(octets[15] as usize) ^ (addr.port() as usize)
}
};
hash % QUEUE_SHARD_COUNT
}
}
+266 -18
View File
@@ -1,38 +1,95 @@
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
/// IP families on which the DHT server listens and crawls.
pub enum NetMode {
/// Bind and crawl IPv4 only.
Ipv4Only,
/// Bind and crawl IPv6 only.
Ipv6Only,
#[default]
/// Bind separate IPv4 and IPv6 sockets.
DualStack,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
/// Validated torrent metadata delivered to the application callback.
pub struct TorrentInfo {
/// Lowercase hexadecimal SHA1 of the bencoded info dictionary.
pub info_hash: String,
/// Magnet URI containing the InfoHash.
pub magnet_link: String,
/// Torrent display name.
pub name: String,
/// Sum of file sizes in bytes.
pub total_size: u64,
/// Files described by the torrent.
pub files: Vec<FileInfo>,
/// Torrent piece length in bytes, or zero if absent.
pub piece_length: u64,
/// Peer addresses used to obtain the Metadata.
pub peers: Vec<String>,
/// Completion time as Unix seconds.
pub timestamp: u64,
}
/// Final outcome of a metadata fetch that passed the admission callback.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataFetchCompletionStatus {
/// Metadata was fetched and accepted by the torrent callback.
Accepted,
/// All available peer candidates failed.
FetchFailed,
/// Metadata was fetched, but the application did not accept it.
DeliveryRejected,
}
/// Report emitted exactly once after an admitted metadata fetch finishes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetadataFetchCompletion {
/// InfoHash that reached a terminal state.
pub info_hash: String,
/// Final download/delivery status.
pub status: MetadataFetchCompletionStatus,
/// Real Peer network attempts; failure-cache skips are excluded.
pub attempts: usize,
}
impl MetadataFetchCompletion {
/// Returns true only for [`MetadataFetchCompletionStatus::Accepted`].
pub fn is_success(&self) -> bool {
self.status == MetadataFetchCompletionStatus::Accepted
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
/// One file entry from the validated info dictionary.
pub struct FileInfo {
/// Slash-separated relative path.
pub path: String,
/// File size in bytes.
pub size: u64,
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
/// Compact DHT node tuple used by crawl and routing code.
pub struct NodeTuple {
/// Twenty-byte DHT node ID.
pub id: [u8; 20],
/// Public UDP endpoint.
pub addr: SocketAddr,
}
impl TorrentInfo {
/// Formats [`Self::total_size`] using binary thresholds and a short unit suffix.
pub fn format_size(&self) -> String {
format_bytes(self.total_size)
}
}
impl FileInfo {
/// Formats [`Self::size`] using binary thresholds and a short unit suffix.
pub fn format_size(&self) -> String {
format_bytes(self.size)
}
@@ -42,42 +99,233 @@ fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, UNITS[unit_index])
format!("{size:.2} {}", UNITS[unit_index])
}
#[derive(Debug, Clone)]
/// Complete server configuration.
pub struct DHTOptions {
/// UDP listen port.
pub port: u16,
pub metadata_timeout: u64,
pub max_metadata_queue_size: usize,
pub max_metadata_worker_count: usize,
/// Enabled IP families.
pub netmode: NetMode,
pub node_queue_capacity: usize,
/// Capacity between announce processing and the Metadata scheduler.
pub hash_queue_capacity: usize,
/// Metadata download and Peer-cache limits.
pub metadata: MetadataOptions,
/// Active crawl, node-pool and scheduler limits.
pub crawl: CrawlOptions,
}
#[derive(Debug, Clone)]
/// Metadata download and failure-cache limits.
pub struct MetadataOptions {
/// End-to-end timeout for one Peer attempt, in seconds.
pub timeout_secs: u64,
/// Maximum number of deduplicated pending InfoHashes.
pub max_queue_size: usize,
/// Maximum number of concurrent Metadata jobs.
pub max_worker_count: usize,
/// Maximum number of cached bad Peer socket addresses.
pub peer_failure_cache_capacity: usize,
/// Timeout/connect failure cache lifetime in seconds.
pub peer_failure_ttl_secs: u64,
}
#[derive(Debug, Clone, Default)]
/// Active crawl configuration grouped by responsibility.
pub struct CrawlOptions {
/// FIFO node-pool and responsive-ring limits.
pub pool: PoolOptions,
/// Query, replacement and response budgets.
pub rate_limit: RateLimitOptions,
/// Bootstrap sources and retry policy.
pub bootstrap: BootstrapOptions,
/// Target-generation policy.
pub target: TargetOptions,
/// Internal bounded-channel and snapshot limits.
pub scheduler: SchedulerOptions,
}
#[derive(Debug, Clone)]
/// Independent crawl and UDP-response budgets.
pub struct RateLimitOptions {
/// Maximum active find_node queries scheduled per second.
pub max_find_node_rate_per_sec: u32,
/// Maximum query budget consumed in one scheduler tick.
pub burst: u32,
/// Maximum total pending find_node transactions.
pub max_in_flight: usize,
/// Pending find_node timeout in seconds.
pub request_timeout_secs: u64,
/// Maximum never-before-probed destinations per minute.
pub max_new_destinations_per_minute: u32,
/// Maximum outbound DHT response packets per second.
pub max_response_rate_per_sec: u32,
/// Maximum encoded outbound DHT response bytes per second.
pub max_response_bytes_per_sec: u64,
/// Maximum response packets per source address per second.
pub max_response_rate_per_source: u32,
/// Remaining query-rate percentage when Metadata pressure reaches 95%.
pub metadata_pressure_floor_percent: u8,
/// Maximum FIFO replacements per minute after the pool has warmed.
pub max_replacements_per_minute: u32,
/// Maximum pending find_node transactions per IP subnet.
pub max_in_flight_per_subnet: usize,
}
#[derive(Debug, Clone)]
/// FIFO crawl-pool and responsive-node reservoir limits.
pub struct PoolOptions {
/// Maximum queued crawl nodes.
pub capacity: usize,
/// How long a probed endpoint is blocked from readmission, in seconds.
pub recent_probe_ttl_secs: u64,
/// Maximum nodes retained for replies and revisit traffic.
pub responsive_capacity: usize,
/// Responsive-node lifetime in seconds.
pub responsive_ttl_secs: u64,
/// Pool size below which bootstrap is considered.
pub low_watermark: usize,
}
#[derive(Debug, Clone)]
/// Bootstrap hostnames and retry timing.
pub struct BootstrapOptions {
/// Host:port sources resolved when bootstrap is needed.
pub nodes: Vec<String>,
/// Minimum interval between bootstrap rounds, in seconds.
pub interval_secs: u64,
/// Maximum resolved endpoints selected in one round.
pub max_nodes_per_round: usize,
/// Initial failed-source backoff in seconds.
pub source_backoff_base_secs: u64,
/// Maximum failed-source backoff in seconds.
pub source_backoff_max_secs: u64,
}
#[derive(Debug, Clone)]
/// Distribution used to generate find_node targets and sender IDs.
pub struct TargetOptions {
/// Percentage of targets that are fully random.
pub random_walk_percent: u8,
/// Percentage of targets chosen from sparse routing buckets.
pub sparse_bucket_percent: u8,
/// Whether outbound sender IDs borrow the target's prefix.
pub neighbor_sender_id: bool,
}
#[derive(Debug, Clone)]
/// Capacities and batch limits for the crawl actor.
pub struct SchedulerOptions {
/// Capacity for response/bootstrap priority events.
pub priority_event_channel_capacity: usize,
/// Capacity for newly discovered node events.
pub discovery_event_channel_capacity: usize,
/// Maximum events drained per actor iteration.
pub event_batch_limit: usize,
/// Maximum discovery nodes drained per actor iteration.
pub node_batch_limit: usize,
/// Maximum responsive nodes published in the lock-free snapshot.
pub routing_snapshot_size: usize,
/// Snapshot publication interval in milliseconds.
pub snapshot_refresh_millis: u64,
}
impl Default for DHTOptions {
fn default() -> Self {
Self {
port: 6881,
metadata_timeout: 3,
max_metadata_queue_size: 100000,
max_metadata_worker_count: 1000,
netmode: NetMode::Ipv4Only,
node_queue_capacity: 100000,
hash_queue_capacity: 10000,
hash_queue_capacity: 10_000,
metadata: MetadataOptions::default(),
crawl: CrawlOptions::default(),
}
}
}
impl Default for MetadataOptions {
fn default() -> Self {
Self {
timeout_secs: 4,
max_queue_size: 10_000,
max_worker_count: 256,
peer_failure_cache_capacity: 200_000,
peer_failure_ttl_secs: 60,
}
}
}
impl Default for RateLimitOptions {
fn default() -> Self {
Self {
max_find_node_rate_per_sec: 200,
burst: 40,
max_in_flight: 512,
request_timeout_secs: 2,
max_new_destinations_per_minute: 10_000,
max_response_rate_per_sec: 500,
max_response_bytes_per_sec: 1024 * 1024,
max_response_rate_per_source: 40,
metadata_pressure_floor_percent: 25,
max_replacements_per_minute: 25_000,
max_in_flight_per_subnet: 8,
}
}
}
impl Default for PoolOptions {
fn default() -> Self {
Self {
capacity: 100_000,
recent_probe_ttl_secs: 600,
responsive_capacity: 16_384,
responsive_ttl_secs: 900,
low_watermark: 10_000,
}
}
}
impl Default for BootstrapOptions {
fn default() -> Self {
Self {
nodes: vec![
"router.bittorrent.com:6881".to_string(),
"dht.transmissionbt.com:6881".to_string(),
"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,
source_backoff_max_secs: 3_600,
}
}
}
impl Default for TargetOptions {
fn default() -> Self {
Self {
random_walk_percent: 70,
sparse_bucket_percent: 30,
neighbor_sender_id: true,
}
}
}
impl Default for SchedulerOptions {
fn default() -> Self {
Self {
priority_event_channel_capacity: 8_192,
discovery_event_channel_capacity: 16_384,
event_batch_limit: 256,
node_batch_limit: 4_096,
routing_snapshot_size: 4_096,
snapshot_refresh_millis: 1_000,
}
}
}
+79
View File
@@ -0,0 +1,79 @@
//! UDP 收包缓冲区池:避免每包 `to_owned()` 拷贝。
//!
//! 单线程 listener 从池中取出固定大小缓冲区,`recv_from` 直接写入;
//! 通过 channel 将缓冲区所有权交给 worker,处理完毕后归还池中复用。
use crossbeam_queue::ArrayQueue;
use std::sync::Arc;
/// 与 `process_udp_packet` 中丢弃阈值一致
pub const MAX_DHT_UDP_PACKET: usize = 8192;
/// 预分配缓冲区数量(约等于高峰在途包数)
const INITIAL_POOL_SIZE: usize = 512;
/// 池上限,防止极端背压下无限增长
const MAX_POOL_SIZE: usize = 4096;
/// 在途 UDP 包:固定容量缓冲区 + 有效长度
pub struct UdpPacket {
pub buf: Box<[u8]>,
pub len: usize,
}
impl UdpPacket {
#[inline]
pub fn payload(&self) -> &[u8] {
&self.buf[..self.len]
}
}
/// 固定 8KiB 缓冲区的对象池(`recv_from` 零拷贝移交 worker
#[derive(Clone)]
pub struct UdpBufferPool {
inner: Arc<PoolInner>,
}
struct PoolInner {
free: ArrayQueue<Box<[u8]>>,
buf_capacity: usize,
}
impl UdpBufferPool {
pub fn new() -> Self {
let free = ArrayQueue::new(MAX_POOL_SIZE);
for _ in 0..INITIAL_POOL_SIZE {
let _ = free.push(alloc_buffer(MAX_DHT_UDP_PACKET));
}
Self {
inner: Arc::new(PoolInner {
free,
buf_capacity: MAX_DHT_UDP_PACKET,
}),
}
}
/// 取一块缓冲区;池空时分配新块(背压或突发流量)
pub fn acquire(&self) -> Box<[u8]> {
self.inner
.free
.pop()
.unwrap_or_else(|| alloc_buffer(self.inner.buf_capacity))
}
/// 归还缓冲区;池满时直接丢弃,由 GC 回收
pub fn release(&self, buf: Box<[u8]>) {
if buf.len() != self.inner.buf_capacity {
return;
}
let _ = self.inner.free.push(buf);
}
pub fn buf_capacity(&self) -> usize {
self.inner.buf_capacity
}
}
fn alloc_buffer(capacity: usize) -> Box<[u8]> {
let v = vec![0; capacity];
v.into_boxed_slice()
}
+345
View File
@@ -0,0 +1,345 @@
use crate::error::DHTError;
use crate::runtime_stats::DhtRuntimeStats;
use crate::udp_buffer::{MAX_DHT_UDP_PACKET, UdpBufferPool, UdpPacket};
#[cfg(feature = "metrics")]
use metrics::counter;
use std::hash::{Hash, Hasher};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UdpSocket;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub(crate) type WorkerHandle = mpsc::Sender<(UdpPacket, SocketAddr, SocketAddr)>;
pub(crate) fn spawn_udp_listener(
socket: Arc<UdpSocket>,
mut workers: Vec<WorkerHandle>,
shutdown: CancellationToken,
buffer_pool: UdpBufferPool,
runtime_stats: DhtRuntimeStats,
) -> crate::error::Result<()> {
let local_addr = socket
.local_addr()
.map_err(|e| DHTError::Init(format!("socket local addr failed: {e}")))?;
if workers.is_empty() {
return Err(DHTError::Init(
"spawn_udp_listener: no worker provided".to_string(),
));
}
tokio::spawn(async move {
loop {
let mut buf = buffer_pool.acquire();
let recv_buf = &mut buf[..buffer_pool.buf_capacity()];
tokio::select! {
_ = shutdown.cancelled() => {
buffer_pool.release(buf);
break;
}
result = socket.recv_from(recv_buf) => {
match result {
Ok((size, origin_addr)) => {
if let Err(ProcessUdpPacketError::NoLiveWorkers) =
process_udp_packet(buf, size, origin_addr, local_addr, &buffer_pool, &runtime_stats, &mut workers)
{
log::warn!("Socket {socket:?} is closing because no worker can process packets.");
break
}
}
Err(_) => {
buffer_pool.release(buf);
tokio::select! {
_ = shutdown.cancelled() => break,
_ = tokio::time::sleep(Duration::from_millis(1)) => {},
}
}
}
}
}
}
});
Ok(())
}
enum ProcessUdpPacketError {
PacketTooLarge,
InvalidPacket,
ChokedWorkers,
NoLiveWorkers,
}
fn process_udp_packet(
buf: Box<[u8]>,
size: usize,
origin_addr: SocketAddr,
local_addr: SocketAddr,
buffer_pool: &UdpBufferPool,
runtime_stats: &DhtRuntimeStats,
workers: &mut Vec<WorkerHandle>,
) -> std::result::Result<(), ProcessUdpPacketError> {
runtime_stats.udp_received();
runtime_stats.udp_received_bytes(size);
#[cfg(feature = "metrics")]
counter!("dht_udp_bytes_received_total").increment(size as u64);
if size > MAX_DHT_UDP_PACKET {
runtime_stats.udp_invalid();
#[cfg(feature = "metrics")]
counter!("dht_udp_packets_received_total", "status" => "dropped_size").increment(1);
buffer_pool.release(buf);
return Err(ProcessUdpPacketError::PacketTooLarge);
}
if size == 0 || buf[0] != b'd' {
runtime_stats.udp_invalid();
#[cfg(feature = "metrics")]
counter!("dht_udp_packets_received_total", "status" => "dropped_magic").increment(1);
buffer_pool.release(buf);
return Err(ProcessUdpPacketError::InvalidPacket);
}
let mut packet = UdpPacket { buf, len: size };
let mut hasher = ahash::AHasher::default();
origin_addr.hash(&mut hasher);
let origin_hash = hasher.finish() as usize;
'select_worker: loop {
if workers.is_empty() {
buffer_pool.release(packet.buf);
return Err(ProcessUdpPacketError::NoLiveWorkers);
}
let worker_count = workers.len();
let preferred_index = origin_hash % worker_count;
for offset in 0..worker_count {
let worker_index = (preferred_index + offset) % worker_count;
match workers[worker_index].try_send((packet, origin_addr, local_addr)) {
Ok(_) => {
#[cfg(feature = "metrics")]
counter!("dht_udp_packets_received_total", "status" => "ok").increment(1);
return Ok(());
}
Err(mpsc::error::TrySendError::Full((p, _, _))) => {
packet = p;
}
Err(mpsc::error::TrySendError::Closed((p, _, _))) => {
packet = p;
log::warn!("UDP worker dropped.");
workers.swap_remove(worker_index);
continue 'select_worker;
}
}
}
#[cfg(feature = "metrics")]
counter!("dht_udp_packets_received_total", "status" => "queue_full").increment(1);
runtime_stats.udp_queue_full();
buffer_pool.release(packet.buf);
return Err(ProcessUdpPacketError::ChokedWorkers);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn addresses() -> (SocketAddr, SocketAddr) {
(
"8.8.8.8:6881".parse().unwrap(),
"0.0.0.0:12313".parse().unwrap(),
)
}
fn buffer(pool: &UdpBufferPool, first: u8) -> Box<[u8]> {
let mut buf = pool.acquire();
buf[0] = first;
buf
}
fn packet(pool: &UdpBufferPool, first: u8) -> UdpPacket {
UdpPacket {
buf: buffer(pool, first),
len: 1,
}
}
fn preferred_index(origin_addr: SocketAddr, worker_count: usize) -> usize {
let mut hasher = ahash::AHasher::default();
origin_addr.hash(&mut hasher);
(hasher.finish() as usize) % worker_count
}
#[test]
fn available_preferred_worker_is_used_first() {
let pool = UdpBufferPool::new();
let stats = DhtRuntimeStats::default();
let (origin_addr, local_addr) = addresses();
let (tx0, mut rx0) = mpsc::channel(1);
let (tx1, mut rx1) = mpsc::channel(1);
let mut workers = vec![tx0, tx1];
let preferred = preferred_index(origin_addr, workers.len());
assert!(
process_udp_packet(
buffer(&pool, b'd'),
1,
origin_addr,
local_addr,
&pool,
&stats,
&mut workers,
)
.is_ok()
);
let (preferred_rx, fallback_rx) = if preferred == 0 {
(&mut rx0, &mut rx1)
} else {
(&mut rx1, &mut rx0)
};
let (forwarded, _, _) = preferred_rx.try_recv().unwrap();
assert_eq!(forwarded.payload(), b"d");
assert!(matches!(
fallback_rx.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
pool.release(forwarded.buf);
}
#[test]
fn full_preferred_worker_falls_back_to_available_worker() {
let pool = UdpBufferPool::new();
let stats = DhtRuntimeStats::default();
let (origin_addr, local_addr) = addresses();
let (tx0, mut rx0) = mpsc::channel(1);
let (tx1, mut rx1) = mpsc::channel(1);
let mut workers = vec![tx0, tx1];
let preferred = preferred_index(origin_addr, workers.len());
workers[preferred]
.try_send((packet(&pool, b'x'), origin_addr, local_addr))
.unwrap();
assert!(
process_udp_packet(
buffer(&pool, b'd'),
1,
origin_addr,
local_addr,
&pool,
&stats,
&mut workers,
)
.is_ok()
);
let (preferred_rx, fallback_rx) = if preferred == 0 {
(&mut rx0, &mut rx1)
} else {
(&mut rx1, &mut rx0)
};
let (queued, _, _) = preferred_rx.try_recv().unwrap();
let (forwarded, _, _) = fallback_rx.try_recv().unwrap();
assert_eq!(queued.payload(), b"x");
assert_eq!(forwarded.payload(), b"d");
pool.release(queued.buf);
pool.release(forwarded.buf);
}
#[test]
fn closed_preferred_worker_is_removed_before_fallback() {
let pool = UdpBufferPool::new();
let stats = DhtRuntimeStats::default();
let (origin_addr, local_addr) = addresses();
let (closed_tx, closed_rx) = mpsc::channel(1);
drop(closed_rx);
let (open_tx, mut open_rx) = mpsc::channel(1);
let preferred = preferred_index(origin_addr, 2);
let mut workers = if preferred == 0 {
vec![closed_tx, open_tx]
} else {
vec![open_tx, closed_tx]
};
assert!(
process_udp_packet(
buffer(&pool, b'd'),
1,
origin_addr,
local_addr,
&pool,
&stats,
&mut workers,
)
.is_ok()
);
assert_eq!(workers.len(), 1);
let (forwarded, _, _) = open_rx.try_recv().unwrap();
assert_eq!(forwarded.payload(), b"d");
pool.release(forwarded.buf);
}
#[test]
fn packet_is_dropped_only_after_all_live_workers_are_full() {
let pool = UdpBufferPool::new();
let stats = DhtRuntimeStats::default();
let (origin_addr, local_addr) = addresses();
let (tx0, mut rx0) = mpsc::channel(1);
let (tx1, mut rx1) = mpsc::channel(1);
let mut workers = vec![tx0, tx1];
for worker in &workers {
worker
.try_send((packet(&pool, b'x'), origin_addr, local_addr))
.unwrap();
}
let result = process_udp_packet(
buffer(&pool, b'd'),
1,
origin_addr,
local_addr,
&pool,
&stats,
&mut workers,
);
assert!(matches!(result, Err(ProcessUdpPacketError::ChokedWorkers)));
let snapshot = stats.snapshot();
assert_eq!(snapshot.udp_received, 1);
assert_eq!(snapshot.udp_queue_full, 1);
assert_eq!(snapshot.udp_invalid, 0);
assert_eq!(workers.len(), 2);
for receiver in [&mut rx0, &mut rx1] {
let (queued, _, _) = receiver.try_recv().unwrap();
assert_eq!(queued.payload(), b"x");
pool.release(queued.buf);
}
}
#[test]
fn invalid_packet_updates_runtime_stats() {
let pool = UdpBufferPool::new();
let stats = DhtRuntimeStats::default();
let (origin_addr, local_addr) = addresses();
let mut workers = Vec::new();
let result = process_udp_packet(
buffer(&pool, b'x'),
1,
origin_addr,
local_addr,
&pool,
&stats,
&mut workers,
);
assert!(matches!(result, Err(ProcessUdpPacketError::InvalidPacket)));
let snapshot = stats.snapshot();
assert_eq!(snapshot.udp_received, 1);
assert_eq!(snapshot.udp_invalid, 1);
assert_eq!(snapshot.udp_queue_full, 0);
}
}