From 28fa69e6140cf5fb088030481d31ca677c6f512a Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 10 Aug 2026 15:00:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E7=BB=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=B9=B6=E5=AE=8C=E5=96=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 23 +- BENCHMARKS.md | 2 +- Cargo.lock | 74 ++ Cargo.toml | 2 +- README.md | 16 +- TESTING.md | 4 +- TODOS.md | 24 +- dht-search.example.toml | 10 +- dht-search.filter-test.toml | 2 +- dht-search/src/config.rs | 637 ------------------ dht-search/src/lib.rs | 5 - scripts/run.bat | 14 +- {dht-crawler => src/crawler}/CHANGELOG.md | 0 {dht-crawler => src/crawler}/Cargo.toml | 0 {dht-crawler => src/crawler}/README.md | 2 +- {dht-crawler => src/crawler}/docs/metrics.md | 0 {dht-crawler => src/crawler}/examples/main.rs | 0 {dht-crawler => src/crawler}/src/addr.rs | 0 {dht-crawler => src/crawler}/src/bootstrap.rs | 0 {dht-crawler => src/crawler}/src/budget.rs | 0 .../crawler}/src/crawl_config.rs | 0 .../crawler}/src/crawl_engine.rs | 0 {dht-crawler => src/crawler}/src/error.rs | 0 {dht-crawler => src/crawler}/src/krpc.rs | 0 {dht-crawler => src/crawler}/src/lib.rs | 12 +- {dht-crawler => src/crawler}/src/metadata.rs | 0 {dht-crawler => src/crawler}/src/node_id.rs | 0 {dht-crawler => src/crawler}/src/node_pool.rs | 0 .../crawler}/src/peer_lookup.rs | 5 +- {dht-crawler => src/crawler}/src/protocol.rs | 0 .../crawler}/src/response_limiter.rs | 0 .../crawler}/src/routing_snapshot.rs | 0 .../crawler}/src/runtime_stats.rs | 0 .../crawler}/src/sample_infohashes.rs | 0 {dht-crawler => src/crawler}/src/scheduler.rs | 4 +- {dht-crawler => src/crawler}/src/server.rs | 16 +- {dht-crawler => src/crawler}/src/types.rs | 13 + .../crawler}/src/udp_buffer.rs | 0 .../crawler}/src/udp_ingress.rs | 0 {dht-search => src/search}/Cargo.toml | 15 +- {dht-search => src/search}/README.md | 40 +- .../search}/src/api/handlers.rs | 86 ++- {dht-search => src/search}/src/api/mod.rs | 177 ++++- {dht-search => src/search}/src/api/request.rs | 18 +- .../search}/src/api/response.rs | 9 +- {dht-search => src/search}/src/app.rs | 95 ++- {dht-search => src/search}/src/backup.rs | 16 +- .../search}/src/bin/dht-benchmark.rs | 0 .../search}/src/bin/dht-benchmark/config.rs | 0 .../search}/src/bin/dht-benchmark/dataset.rs | 12 +- .../search}/src/bin/dht-benchmark/metrics.rs | 0 .../search}/src/bin/dht-benchmark/report.rs | 0 .../search}/src/bin/dht-benchmark/runner.rs | 0 .../search}/src/bin/dht-benchmark/workload.rs | 0 src/search/src/config.rs | 222 ++++++ src/search/src/config/model.rs | 268 ++++++++ src/search/src/config/runtime.rs | 238 +++++++ src/search/src/config/service.rs | 221 ++++++ src/search/src/config/store.rs | 155 +++++ src/search/src/crawler/mapper.rs | 23 + {dht-search => src/search}/src/crawler/mod.rs | 1 + .../search}/src/crawler/pipeline.rs | 46 +- src/search/src/diagnostics/http.rs | 166 +++++ src/search/src/diagnostics/mod.rs | 392 +++++++++++ src/search/src/diagnostics/model.rs | 117 ++++ src/search/src/diagnostics/process.rs | 167 +++++ src/search/src/diagnostics/store.rs | 209 ++++++ {dht-search => src/search}/src/disk_guard.rs | 0 .../search}/src/domain/content_filter.rs | 0 .../search}/src/domain/content_group.rs | 11 +- .../search}/src/domain/fingerprint.rs | 0 .../search}/src/domain/info_hash.rs | 0 .../search}/src/domain/metadata.rs | 54 +- {dht-search => src/search}/src/domain/mod.rs | 6 +- .../search}/src/domain/torrent.rs | 14 +- .../src/main.rs => src/search/src/entry.rs | 33 +- {dht-search => src/search}/src/error.rs | 10 +- .../search}/src/index_worker.rs | 2 +- src/search/src/lib.rs | 36 + src/search/src/main.rs | 6 + {dht-search => src/search}/src/monitor.rs | 2 +- .../search}/src/search/document.rs | 0 .../search}/src/search/filter.rs | 0 .../search}/src/search/indexer.rs | 97 ++- {dht-search => src/search}/src/search/mod.rs | 2 +- .../search}/src/search/query.rs | 0 .../search}/src/search/schema.rs | 0 {dht-search => src/search}/src/shutdown.rs | 0 .../search}/src/storage/keys.rs | 0 {dht-search => src/search}/src/storage/mod.rs | 6 +- .../search}/src/storage/repository.rs | 10 + .../search}/src/storage/rocks.rs | 22 +- {dht-search => src/search}/src/telemetry.rs | 0 .../search}/src/verification.rs | 6 +- .../search}/tests/storage_search_flow.rs | 19 +- {web => src/web}/.gitignore | 0 {web => src/web}/README.md | 18 +- {web => src/web}/bun.lock | 19 +- {web => src/web}/components.json | 0 {web => src/web}/index.html | 0 {web => src/web}/package.json | 5 +- {web => src/web}/public/favicon.svg | 0 {web => src/web}/public/icons.svg | 0 src/web/src/App.vue | 101 +++ .../web}/src/components/SearchResultCard.vue | 0 .../src/components/TorrentDetailDialog.vue | 0 .../web}/src/components/ui/button/Button.vue | 0 .../web}/src/components/ui/button/index.ts | 0 .../ui/pagination/AppPagination.vue | 0 .../src/components/ui/pagination/index.ts | 0 .../src/components/ui/select/AppSelect.vue | 0 .../web}/src/components/ui/select/index.ts | 0 {web => src/web}/src/lib/api.ts | 33 +- {web => src/web}/src/lib/format.ts | 0 {web => src/web}/src/lib/utils.ts | 0 {web => src/web}/src/main.ts | 3 +- src/web/src/pages/ConfigPage.vue | 208 ++++++ src/web/src/pages/DiagnosticsPage.vue | 171 +++++ .../web/src/pages/SearchPage.vue | 70 +- src/web/src/router.ts | 13 + {web => src/web}/src/style.css | 33 + src/web/src/types/api.ts | 303 +++++++++ {web => src/web}/tsconfig.app.json | 0 {web => src/web}/tsconfig.json | 0 {web => src/web}/tsconfig.node.json | 0 {web => src/web}/vite.config.ts | 16 +- web/src/types/api.ts | 119 ---- 127 files changed, 3927 insertions(+), 1079 deletions(-) delete mode 100644 dht-search/src/config.rs delete mode 100644 dht-search/src/lib.rs rename {dht-crawler => src/crawler}/CHANGELOG.md (100%) rename {dht-crawler => src/crawler}/Cargo.toml (100%) rename {dht-crawler => src/crawler}/README.md (99%) rename {dht-crawler => src/crawler}/docs/metrics.md (100%) rename {dht-crawler => src/crawler}/examples/main.rs (100%) rename {dht-crawler => src/crawler}/src/addr.rs (100%) rename {dht-crawler => src/crawler}/src/bootstrap.rs (100%) rename {dht-crawler => src/crawler}/src/budget.rs (100%) rename {dht-crawler => src/crawler}/src/crawl_config.rs (100%) rename {dht-crawler => src/crawler}/src/crawl_engine.rs (100%) rename {dht-crawler => src/crawler}/src/error.rs (100%) rename {dht-crawler => src/crawler}/src/krpc.rs (100%) rename {dht-crawler => src/crawler}/src/lib.rs (86%) rename {dht-crawler => src/crawler}/src/metadata.rs (100%) rename {dht-crawler => src/crawler}/src/node_id.rs (100%) rename {dht-crawler => src/crawler}/src/node_pool.rs (100%) rename {dht-crawler => src/crawler}/src/peer_lookup.rs (99%) rename {dht-crawler => src/crawler}/src/protocol.rs (100%) rename {dht-crawler => src/crawler}/src/response_limiter.rs (100%) rename {dht-crawler => src/crawler}/src/routing_snapshot.rs (100%) rename {dht-crawler => src/crawler}/src/runtime_stats.rs (100%) rename {dht-crawler => src/crawler}/src/sample_infohashes.rs (100%) rename {dht-crawler => src/crawler}/src/scheduler.rs (99%) rename {dht-crawler => src/crawler}/src/server.rs (98%) rename {dht-crawler => src/crawler}/src/types.rs (96%) rename {dht-crawler => src/crawler}/src/udp_buffer.rs (100%) rename {dht-crawler => src/crawler}/src/udp_ingress.rs (100%) rename {dht-search => src/search}/Cargo.toml (70%) rename {dht-search => src/search}/README.md (86%) rename {dht-search => src/search}/src/api/handlers.rs (81%) rename {dht-search => src/search}/src/api/mod.rs (67%) rename {dht-search => src/search}/src/api/request.rs (79%) rename {dht-search => src/search}/src/api/response.rs (94%) rename {dht-search => src/search}/src/app.rs (78%) rename {dht-search => src/search}/src/backup.rs (97%) rename {dht-search => src/search}/src/bin/dht-benchmark.rs (100%) rename {dht-search => src/search}/src/bin/dht-benchmark/config.rs (100%) rename {dht-search => src/search}/src/bin/dht-benchmark/dataset.rs (91%) rename {dht-search => src/search}/src/bin/dht-benchmark/metrics.rs (100%) rename {dht-search => src/search}/src/bin/dht-benchmark/report.rs (100%) rename {dht-search => src/search}/src/bin/dht-benchmark/runner.rs (100%) rename {dht-search => src/search}/src/bin/dht-benchmark/workload.rs (100%) create mode 100644 src/search/src/config.rs create mode 100644 src/search/src/config/model.rs create mode 100644 src/search/src/config/runtime.rs create mode 100644 src/search/src/config/service.rs create mode 100644 src/search/src/config/store.rs create mode 100644 src/search/src/crawler/mapper.rs rename {dht-search => src/search}/src/crawler/mod.rs (80%) rename {dht-search => src/search}/src/crawler/pipeline.rs (92%) create mode 100644 src/search/src/diagnostics/http.rs create mode 100644 src/search/src/diagnostics/mod.rs create mode 100644 src/search/src/diagnostics/model.rs create mode 100644 src/search/src/diagnostics/process.rs create mode 100644 src/search/src/diagnostics/store.rs rename {dht-search => src/search}/src/disk_guard.rs (100%) rename {dht-search => src/search}/src/domain/content_filter.rs (100%) rename {dht-search => src/search}/src/domain/content_group.rs (93%) rename {dht-search => src/search}/src/domain/fingerprint.rs (100%) rename {dht-search => src/search}/src/domain/info_hash.rs (100%) rename {dht-search => src/search}/src/domain/metadata.rs (92%) rename {dht-search => src/search}/src/domain/mod.rs (79%) rename {dht-search => src/search}/src/domain/torrent.rs (96%) rename dht-search/src/main.rs => src/search/src/entry.rs (70%) rename {dht-search => src/search}/src/error.rs (74%) rename {dht-search => src/search}/src/index_worker.rs (99%) create mode 100644 src/search/src/lib.rs create mode 100644 src/search/src/main.rs rename {dht-search => src/search}/src/monitor.rs (99%) rename {dht-search => src/search}/src/search/document.rs (100%) rename {dht-search => src/search}/src/search/filter.rs (100%) rename {dht-search => src/search}/src/search/indexer.rs (85%) rename {dht-search => src/search}/src/search/mod.rs (97%) rename {dht-search => src/search}/src/search/query.rs (100%) rename {dht-search => src/search}/src/search/schema.rs (100%) rename {dht-search => src/search}/src/shutdown.rs (100%) rename {dht-search => src/search}/src/storage/keys.rs (100%) rename {dht-search => src/search}/src/storage/mod.rs (63%) rename {dht-search => src/search}/src/storage/repository.rs (92%) rename {dht-search => src/search}/src/storage/rocks.rs (98%) rename {dht-search => src/search}/src/telemetry.rs (100%) rename {dht-search => src/search}/src/verification.rs (99%) rename {dht-search => src/search}/tests/storage_search_flow.rs (89%) rename {web => src/web}/.gitignore (100%) rename {web => src/web}/README.md (71%) rename {web => src/web}/bun.lock (95%) rename {web => src/web}/components.json (100%) rename {web => src/web}/index.html (100%) rename {web => src/web}/package.json (88%) rename {web => src/web}/public/favicon.svg (100%) rename {web => src/web}/public/icons.svg (100%) create mode 100644 src/web/src/App.vue rename {web => src/web}/src/components/SearchResultCard.vue (100%) rename {web => src/web}/src/components/TorrentDetailDialog.vue (100%) rename {web => src/web}/src/components/ui/button/Button.vue (100%) rename {web => src/web}/src/components/ui/button/index.ts (100%) rename {web => src/web}/src/components/ui/pagination/AppPagination.vue (100%) rename {web => src/web}/src/components/ui/pagination/index.ts (100%) rename {web => src/web}/src/components/ui/select/AppSelect.vue (100%) rename {web => src/web}/src/components/ui/select/index.ts (100%) rename {web => src/web}/src/lib/api.ts (57%) rename {web => src/web}/src/lib/format.ts (100%) rename {web => src/web}/src/lib/utils.ts (100%) rename {web => src/web}/src/main.ts (53%) create mode 100644 src/web/src/pages/ConfigPage.vue create mode 100644 src/web/src/pages/DiagnosticsPage.vue rename web/src/App.vue => src/web/src/pages/SearchPage.vue (68%) create mode 100644 src/web/src/router.ts rename {web => src/web}/src/style.css (85%) create mode 100644 src/web/src/types/api.ts rename {web => src/web}/tsconfig.app.json (100%) rename {web => src/web}/tsconfig.json (100%) rename {web => src/web}/tsconfig.node.json (100%) rename {web => src/web}/vite.config.ts (55%) delete mode 100644 web/src/types/api.ts diff --git a/AGENTS.md b/AGENTS.md index 0eb07e3..0315e8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,11 @@ 本项目用于持续或间歇地从 BitTorrent DHT 网络发现 infohash 获取元数据并提供本地全文搜索和高性能过滤能力 -基础 DHT 协议和抓取能力保留在 `dht-crawler` 中 +基础 DHT 协议和抓取能力保留在 `src/crawler` 中 -面向最终用户运行的服务代码统一放在 `dht-search` 中 +面向最终用户运行的服务代码统一放在 `src/search` 中 + +Web 前端代码统一放在 `src/web` 中 ## 技术方案 @@ -17,6 +19,7 @@ - BLAKE3 负责计算规范化内容结构指纹 - Serde 负责配置领域对象和接口数据的序列化 - Tracing 负责结构化日志和故障定位 +- SQLite 负责有保留上限的运行诊断历史且不得成为业务权威数据源 RocksDB 是唯一权威数据源 @@ -72,11 +75,22 @@ Bloom Filter 只能作为前置加速结构不得作为最终去重依据 - `search` 只负责 Tantivy schema 文档转换索引和查询 - `api` 只负责 HTTP 协议参数校验和响应转换 - `config` 只负责读取校验和暴露配置 +- `diagnostics` 只负责采集聚合和查询可删除的运行指标历史 - `telemetry` 只负责日志指标和运行观测 - `shutdown` 只负责关闭信号和优雅退出协调 模块之间通过明确的数据结构和 trait 通信不得跨层直接访问内部实现 +配置文件只是配置 DTO 的持久化适配器 + +用户配置 DTO 运行时解析结果和配置存储实现必须保持独立边界 + +配置更新必须先完整校验再通过同目录临时文件同步和原子替换保存 + +配置接口必须使用修订号阻止并发请求静默覆盖且不得假装未实际支持的在线热更新 + +领域层不得直接依赖 `dht-crawler` 的回调或传输 DTO + ## 组合和文件边界 - trait 只用于存储搜索网络回调等真实替换边界 不创建只有一个调用方的抽象基类或通用 Service 层 @@ -98,16 +112,17 @@ Bloom Filter 只能作为前置加速结构不得作为最终去重依据 - Tantivy 写入使用批量提交并明确控制 IndexWriter 内存预算 - 日志不得输出完整 Metadata 或大文件列表 - 长期运行的集合必须有容量上限过期规则或磁盘持久化方案 +- 诊断历史必须使用独立 SQLite 数据库并通过采样降级和保留策略限制增长 ## 开发规则 -新业务代码写入 `dht-search` +新业务代码写入 `src/search` 项目阶段任务完成状态和验收标准统一维护在根目录 `TODOS.md` 需求实现或技术决策发生变化时必须同步更新 `TODOS.md` -`dht-crawler` 只接受可复用的 DHT 基础能力不得包含数据库搜索接口或部署逻辑 +`src/crawler` 只接受可复用的 DHT 基础能力不得包含数据库搜索接口或部署逻辑 每个 Rust 文件顶部必须使用中文行注释描述该文件的功能边界且注释行尾不添加标点 diff --git a/BENCHMARKS.md b/BENCHMARKS.md index 8b4f9d1..62c4873 100644 --- a/BENCHMARKS.md +++ b/BENCHMARKS.md @@ -2,7 +2,7 @@ 本文档保存可重复的规模基准条件目标和已验证结果 -完整运行方式和参数说明见 [`dht-search/README.md`](dht-search/README.md) +完整运行方式和参数说明见 [`src/search/README.md`](src/search/README.md) ## 基准条件 diff --git a/Cargo.lock b/Cargo.lock index 37b4b66..2b9f8bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,9 +659,11 @@ dependencies = [ "dunce", "fs2", "hex", + "libc", "regex", "rmp-serde", "rocksdb", + "rusqlite", "serde", "serde_json", "tantivy", @@ -787,6 +789,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastdivide" version = "0.4.2" @@ -1051,6 +1065,18 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -1438,6 +1464,17 @@ dependencies = [ "lz4-sys", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.29" @@ -2155,6 +2192,31 @@ dependencies = [ "librocksdb-sys", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.19", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rust-stemmers" version = "1.2.0" @@ -2454,6 +2516,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7db45ad..9f7574d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["dht-crawler", "dht-search"] +members = ["src/crawler", "src/search"] exclude = ["opencodes"] resolver = "3" diff --git a/README.md b/README.md index ebaf6e1..eb3f8a9 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,15 @@ ## 目录 ```text -dht-search/ 最终运行的采集存储搜索和接口应用 -dht-crawler/ 可独立复用的 DHT 协议与 Metadata 获取基础库 -web/ 基于 Vue 和 shadcn-vue 的本地搜索界面 +src/search/ 最终运行的采集存储搜索和接口应用 +src/crawler/ 可独立复用的 DHT 协议与 Metadata 获取基础库 +src/web/ 基于 Vue 和 shadcn-vue 的本地搜索界面 opencodes/ 不参与构建的参考项目 ``` -当前已完成 DHT 采集持久化全文搜索内容聚合可用性验证 HTTP API 和本地 Web 搜索界面 +当前已完成 DHT 采集持久化全文搜索内容聚合可用性验证 HTTP API 本地 Web 搜索界面 有界 SQLite 运行诊断历史和配置管理 -基础库的使用方式和指标说明见 [`dht-crawler/README.md`](dht-crawler/README.md) +基础库的使用方式和指标说明见 [`src/crawler/README.md`](src/crawler/README.md) 当前实施阶段和后续计划见 [`TODOS.md`](TODOS.md) @@ -25,10 +25,12 @@ Rust 测试分层和默认验证命令见 [`TESTING.md`](TESTING.md) 无效文件隐藏规则见 [`content-filters.toml`](content-filters.toml) -应用构建运行和 API 文档见 [`dht-search/README.md`](dht-search/README.md) +应用构建运行和 API 文档见 [`src/search/README.md`](src/search/README.md) -Web 开发和构建方式见 [`web/README.md`](web/README.md) +Web 开发和构建方式见 [`src/web/README.md`](src/web/README.md) 开发环境可以直接运行 `scripts\run.bat` 在当前窗口同时启动 Rust 后端和 Web 前端 按一次 `Ctrl+C` 即可统一停止 +启动脚本使用本地 `dht-search.toml` 文件 文件不存在时会从 `dht-search.example.toml` 创建 因此 Web 配置页不会修改版本库中的模板 + 任一服务异常退出时启动脚本会清理 Cargo Bun 及其子进程树 避免遗留 Vite 或后端进程 diff --git a/TESTING.md b/TESTING.md index 3e55618..600de5d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -18,11 +18,13 @@ RocksDB adapter 测试需要验证原子批处理私有键空间租约和损坏状态 因此保留在 `storage::rocks` 内部 +SQLite 诊断组件测试需要验证 WAL 持久化分钟合并保留清理和 writer 关闭 因此保留在 `diagnostics` 模块内部 + ## 集成测试 只使用 crate 公开 API 的跨层契约放在 crate 根目录 `tests/` -`dht-search/tests/storage_search_flow.rs` 从外部组合领域模型 RocksDB repository 和 Tantivy search 验证写入索引查询关闭重开和恢复 +`src/search/tests/storage_search_flow.rs` 从外部组合领域模型 RocksDB repository 和 Tantivy search 验证写入索引查询关闭重开和恢复 集成测试不得依赖 `pub(crate)` 或为测试扩大生产 API 可见性 diff --git a/TODOS.md b/TODOS.md index 6cc6c30..1f2600e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -16,11 +16,14 @@ ## 当前技术方向 -- `dht-crawler` 负责可复用的 DHT 协议节点发现 Peer 查找和 Metadata 下载 -- `dht-search` 负责持久化去重索引搜索接口配置和运行生命周期 +- `src/crawler` 负责可复用的 DHT 协议节点发现 Peer 查找和 Metadata 下载 +- `src/search` 负责持久化去重索引搜索接口配置和运行生命周期 +- `src/web` 负责最终用户搜索诊断和配置管理界面 - RocksDB 保存权威数据去重信息和任务状态 - Tantivy 保存可以从 RocksDB 重建的搜索索引 - Axum 提供搜索详情统计和健康检查接口 +- 用户配置使用强类型 DTO 表达 TOML 只是当前持久化适配器 +- SQLite 保存有明确保留上限且可安全删除的运行诊断历史 - 所有长期任务通过有界队列和背压控制资源占用 如果实际运行证明 RocksDB 的构建部署或资源成本不合适可以重新评估 redb SQLite 或其他存储方案 @@ -33,7 +36,7 @@ ### 任务 -- [x] 将 workspace 扁平化为 `dht-crawler` 和 `dht-search` +- [x] 将 crawler search 和 web 源码统一收纳到根目录 `src` - [x] 使用当前 Git 配置统一作者仓库许可证和 edition 元数据 - [x] 编写 `AGENTS.md` 记录架构边界和开发约定 - [x] 将最终应用与可复用 DHT 基础库分离 @@ -183,7 +186,7 @@ - [x] 修复一键启动脚本只停止父进程导致 Vite 子进程残留的问题 - [x] 定义统一错误响应 - [x] 限制查询长度分页大小和最大 offset -- [ ] 增加请求延迟错误率和并发指标 +- [x] 增加请求延迟错误率和并发指标 - [x] 增加搜索详情字段和按需验证入队 API 端到端测试 - [x] 增加搜索过滤折叠精确哈希和变体接口测试 @@ -333,7 +336,18 @@ - [x] 将规模基准拆分为参数数据集工作负载采样报告和编排模块 - [x] 明确单元组件集成端到端和性能测试层级并增加公开 API 集成测试 - [x] 实现可回滚的无效文件过滤并重建有效内容聚合和搜索索引 +- [x] 将 crawler search 和 web 统一迁移到根目录 `src` 并修复构建脚本文档路径 +- [x] 使用领域 Metadata DTO 切断领域层对 DHT 传输 DTO 的直接依赖 +- [x] 将配置拆分为可序列化 DTO TOML 读取适配器和运行时解析结果 +- [x] 使用独立 SQLite 建立有界运行诊断历史存储 +- [x] 采集进程 RocksDB Tantivy DHT 队列和磁盘资源快照 +- [x] 提供当前诊断快照和原始或分钟历史查询接口 +- [x] 将 HTTP 请求并发客户端错误服务端错误和延迟分布写入诊断历史 +- [x] 增加配置查询完整校验原子保存并发修订和统一重启提示 +- [x] 增加 Web 诊断页和配置管理页 完成二十四小时持续运行并继续观察私有内存 Metadata 成功率候选队列深度和每条成功 Metadata 的网络成本 -随后补充 RocksDB 和 Tantivy 内部资源指标并根据实测继续调优 +RocksDB Tantivy HTTP 和进程资源指标已经接入诊断历史 后续根据长期实测继续调优 + +下一轮结构优化优先拆分 crawler 中的运行统计调度器和抓取引擎以及 search 中的 RocksDB 适配器 不为拆分而新增 crate diff --git a/dht-search.example.toml b/dht-search.example.toml index f5d8c9b..51d27a6 100644 --- a/dht-search.example.toml +++ b/dht-search.example.toml @@ -20,6 +20,14 @@ interval_secs = 21600 retain_checkpoints = 3 create_on_start = true +[diagnostics] +enabled = true +database = "data/diagnostics.sqlite3" +sample_interval_secs = 10 +raw_retention_hours = 24 +minute_retention_days = 30 +queue_capacity = 128 + [logging] directory = "data/logs" file_enabled = true @@ -58,7 +66,7 @@ new_destinations_per_minute = 12000 [http] listen = "127.0.0.1:8080" -web_dir = "web/dist" +web_dir = "src/web/dist" [verification] enabled = true diff --git a/dht-search.filter-test.toml b/dht-search.filter-test.toml index 5f13330..07f8de5 100644 --- a/dht-search.filter-test.toml +++ b/dht-search.filter-test.toml @@ -10,4 +10,4 @@ max_path_depth = 4 [http] listen = "127.0.0.1:8080" -web_dir = "web/dist" +web_dir = "src/web/dist" diff --git a/dht-search/src/config.rs b/dht-search/src/config.rs deleted file mode 100644 index 709a91f..0000000 --- a/dht-search/src/config.rs +++ /dev/null @@ -1,637 +0,0 @@ -// 负责加载校验和提供应用配置但不执行任何业务逻辑 - -use std::{fs, net::SocketAddr, path::PathBuf}; - -use clap::Parser; -use dht_crawler::{ - BootstrapOptions, CrawlOptions, DHTOptions, MetadataOptions, NetMode, PeerLookupOptions, - PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, TargetOptions, -}; -use serde::Deserialize; - -use crate::error::AppError; -use dht_search::domain::{ContentFilter, ContentFilterConfig, MetadataLimits}; - -#[derive(Debug, Parser)] -#[command(name = "dht-search", version, about = "DHT 元数据采集和搜索服务")] -pub(crate) struct Cli { - #[arg(long, default_value = "dht-search.toml")] - config: PathBuf, - #[arg(long)] - data_dir: Option, - #[arg(long)] - run_duration_secs: Option, - #[arg(long)] - restore_checkpoint: Option, -} - -#[derive(Debug)] -pub(crate) struct StartupConfig { - pub(crate) app: AppConfig, - pub(crate) restore_checkpoint: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct AppConfig { - pub(crate) data_dir: PathBuf, - pub(crate) content_filter_file: PathBuf, - pub(crate) persistence_queue_capacity: usize, - pub(crate) stats_interval_secs: u64, - pub(crate) run_duration_secs: Option, - pub(crate) index_batch_size: usize, - pub(crate) index_interval_millis: u64, - pub(crate) metadata_limits: MetadataLimitsConfig, - pub(crate) dht: DhtConfig, - pub(crate) disk_guard: DiskGuardConfig, - pub(crate) backup: BackupConfig, - pub(crate) logging: LoggingConfig, - pub(crate) http: HttpConfig, - pub(crate) verification: VerificationConfig, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct DhtConfig { - pub(crate) port: u16, - pub(crate) netmode: NetworkMode, - pub(crate) hash_queue_capacity: usize, - pub(crate) max_outbound_queries_per_second: u32, - pub(crate) outbound_query_burst: u32, - pub(crate) metadata_timeout_secs: u64, - pub(crate) metadata_queue_capacity: usize, - pub(crate) metadata_workers: usize, - pub(crate) metadata_connects_per_second: u32, - pub(crate) sample_queries_per_second: u32, - pub(crate) sample_max_in_flight: usize, - pub(crate) sample_new_node_percent: u8, - pub(crate) sample_candidate_queue_capacity: usize, - pub(crate) sample_fallback_to_iterative: bool, - pub(crate) peer_lookups_per_second: u32, - pub(crate) peer_lookup_max_active: usize, - pub(crate) find_node_queries_per_second: u32, - pub(crate) find_node_max_in_flight: usize, - pub(crate) new_destinations_per_minute: u32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct HttpConfig { - pub(crate) listen: SocketAddr, - pub(crate) web_dir: PathBuf, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct DiskGuardConfig { - pub(crate) enabled: bool, - pub(crate) check_interval_secs: u64, - pub(crate) minimum_free_bytes: u64, - pub(crate) resume_free_bytes: u64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct LoggingConfig { - pub(crate) directory: PathBuf, - pub(crate) file_enabled: bool, - pub(crate) console_enabled: bool, - pub(crate) rotation: LogRotation, - pub(crate) retain_files: usize, - pub(crate) file_prefix: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct BackupConfig { - pub(crate) enabled: bool, - pub(crate) directory: PathBuf, - pub(crate) interval_secs: u64, - pub(crate) retain_checkpoints: usize, - pub(crate) create_on_start: bool, -} - -#[derive(Debug, Clone, Copy, Default, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum LogRotation { - Minutely, - Hourly, - #[default] - Daily, - Never, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct VerificationConfig { - pub(crate) enabled: bool, - pub(crate) queue_capacity: usize, - pub(crate) max_active: usize, - pub(crate) max_peer_attempts: usize, - pub(crate) lease_secs: u64, - pub(crate) poll_interval_millis: u64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub(crate) struct MetadataLimitsConfig { - pub(crate) max_metadata_bytes: usize, - pub(crate) max_files: usize, - pub(crate) max_name_bytes: usize, - pub(crate) max_path_bytes: usize, - pub(crate) max_path_depth: usize, -} - -#[derive(Debug, Clone, Copy, Default, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub(crate) enum NetworkMode { - #[default] - Ipv4Only, - Ipv6Only, - DualStack, -} - -impl Cli { - pub(crate) fn load(self) -> Result { - let config_path = absolute_path(&self.config)?; - let mut config = if config_path.exists() { - let contents = fs::read_to_string(&config_path)?; - toml::from_str::(&contents)? - } else { - AppConfig::default() - }; - - if let Some(data_dir) = self.data_dir { - config.data_dir = data_dir; - } - if self.run_duration_secs.is_some() { - config.run_duration_secs = self.run_duration_secs; - } - let base = config_path - .parent() - .ok_or_else(|| AppError::Config("配置文件没有父目录".to_owned()))?; - if config.data_dir.is_relative() { - config.data_dir = base.join(&config.data_dir); - } - config.data_dir = normalize_absolute(config.data_dir)?; - if config.content_filter_file.is_relative() { - config.content_filter_file = base.join(&config.content_filter_file); - } - config.content_filter_file = normalize_absolute(config.content_filter_file)?; - if config.logging.directory.is_relative() { - config.logging.directory = base.join(&config.logging.directory); - } - config.logging.directory = normalize_absolute(config.logging.directory)?; - if config.backup.directory.is_relative() { - config.backup.directory = base.join(&config.backup.directory); - } - config.backup.directory = normalize_absolute(config.backup.directory)?; - if config.http.web_dir.is_relative() { - config.http.web_dir = base.join(&config.http.web_dir); - } - config.http.web_dir = normalize_absolute(config.http.web_dir)?; - config.validate()?; - let restore_checkpoint = self - .restore_checkpoint - .map(normalize_absolute) - .transpose()?; - Ok(StartupConfig { - app: config, - restore_checkpoint, - }) - } -} - -impl AppConfig { - pub(crate) fn content_filter(&self) -> Result { - let contents = fs::read_to_string(&self.content_filter_file)?; - let config = toml::from_str::(&contents)?; - ContentFilter::compile(config).map_err(AppError::from) - } - - pub(crate) fn dht_options(&self) -> DHTOptions { - let defaults = DHTOptions::default(); - DHTOptions { - port: self.dht.port, - netmode: self.dht.netmode.into(), - hash_queue_capacity: self.dht.hash_queue_capacity, - max_outbound_queries_per_second: self.dht.max_outbound_queries_per_second, - outbound_query_burst: self.dht.outbound_query_burst, - metadata: MetadataOptions { - timeout_secs: self.dht.metadata_timeout_secs, - max_queue_size: self.dht.metadata_queue_capacity, - max_worker_count: self.dht.metadata_workers, - max_connects_per_second: self.dht.metadata_connects_per_second, - max_metadata_size_bytes: self.metadata_limits.max_metadata_bytes, - ..defaults.metadata - }, - peer_lookup: PeerLookupOptions { - max_lookups_per_second: self.dht.peer_lookups_per_second, - burst: self.dht.peer_lookups_per_second.max(1), - max_active_lookups: self.dht.peer_lookup_max_active, - }, - sample_infohashes: SampleInfohashesOptions { - max_queries_per_second: self.dht.sample_queries_per_second, - burst: self.dht.sample_queries_per_second.max(1), - max_in_flight: self.dht.sample_max_in_flight, - new_node_sample_percent: self.dht.sample_new_node_percent, - candidate_queue_capacity: self.dht.sample_candidate_queue_capacity, - fallback_to_iterative: self.dht.sample_fallback_to_iterative, - ..defaults.sample_infohashes - }, - crawl: CrawlOptions { - pool: PoolOptions { - ..defaults.crawl.pool - }, - rate_limit: RateLimitOptions { - max_find_node_rate_per_sec: self.dht.find_node_queries_per_second, - burst: self.dht.outbound_query_burst, - max_in_flight: self.dht.find_node_max_in_flight, - max_new_destinations_per_minute: self.dht.new_destinations_per_minute, - ..defaults.crawl.rate_limit - }, - bootstrap: BootstrapOptions { - ..defaults.crawl.bootstrap - }, - target: TargetOptions { - ..defaults.crawl.target - }, - scheduler: SchedulerOptions { - ..defaults.crawl.scheduler - }, - }, - } - } - - pub(crate) fn metadata_limits(&self) -> MetadataLimits { - MetadataLimits { - max_files: self.metadata_limits.max_files, - max_name_bytes: self.metadata_limits.max_name_bytes, - max_path_bytes: self.metadata_limits.max_path_bytes, - max_path_depth: self.metadata_limits.max_path_depth, - } - } - - fn validate(&self) -> Result<(), AppError> { - if self.persistence_queue_capacity == 0 { - return Err(AppError::Config( - "persistence_queue_capacity 必须大于零".to_owned(), - )); - } - if self.stats_interval_secs == 0 { - return Err(AppError::Config( - "stats_interval_secs 必须大于零".to_owned(), - )); - } - if self.run_duration_secs == Some(0) { - return Err(AppError::Config( - "run_duration_secs 必须大于零或不设置".to_owned(), - )); - } - if self.index_batch_size == 0 || self.index_interval_millis == 0 { - return Err(AppError::Config( - "索引批量大小和执行间隔必须大于零".to_owned(), - )); - } - if self.metadata_limits.max_metadata_bytes == 0 - || self.metadata_limits.max_files == 0 - || self.metadata_limits.max_name_bytes == 0 - || self.metadata_limits.max_path_bytes == 0 - || self.metadata_limits.max_path_depth == 0 - { - return Err(AppError::Config( - "Metadata 大小文件数名称路径和目录层级上限必须大于零".to_owned(), - )); - } - if self.dht.metadata_workers == 0 || self.dht.metadata_queue_capacity == 0 { - return Err(AppError::Config( - "Metadata worker 和队列容量必须大于零".to_owned(), - )); - } - if self.verification.queue_capacity == 0 - || self.verification.max_active == 0 - || self.verification.max_peer_attempts == 0 - || self.verification.lease_secs == 0 - || self.verification.poll_interval_millis == 0 - { - return Err(AppError::Config( - "验证队列容量并发尝试数租约和轮询间隔必须大于零".to_owned(), - )); - } - if self.disk_guard.check_interval_secs == 0 - || self.disk_guard.minimum_free_bytes == 0 - || self.disk_guard.resume_free_bytes <= self.disk_guard.minimum_free_bytes - { - return Err(AppError::Config( - "磁盘检查间隔必须大于零且恢复阈值必须大于保护阈值".to_owned(), - )); - } - if self.backup.interval_secs == 0 || self.backup.retain_checkpoints == 0 { - return Err(AppError::Config( - "备份间隔和检查点保留数量必须大于零".to_owned(), - )); - } - let database_path = self.data_dir.join("rocksdb"); - if self.backup.directory == database_path - || self.backup.directory.starts_with(&database_path) - { - return Err(AppError::Config( - "检查点目录不能位于 RocksDB 数据库目录内部".to_owned(), - )); - } - if !self.logging.file_enabled && !self.logging.console_enabled { - return Err(AppError::Config( - "文件日志和终端日志不能同时关闭".to_owned(), - )); - } - if self.logging.file_enabled && self.logging.retain_files == 0 { - return Err(AppError::Config("日志保留文件数量必须大于零".to_owned())); - } - if self.logging.file_prefix.trim().is_empty() - || self - .logging - .file_prefix - .chars() - .any(|character| character.is_control() || matches!(character, '/' | '\\')) - { - return Err(AppError::Config( - "日志文件前缀不能为空且不能包含路径分隔符或控制字符".to_owned(), - )); - } - if self.dht.max_outbound_queries_per_second == 0 - || self.dht.outbound_query_burst == 0 - || self.dht.metadata_connects_per_second == 0 - || self.dht.sample_max_in_flight == 0 - || self.dht.sample_candidate_queue_capacity == 0 - || self.dht.peer_lookup_max_active == 0 - || self.dht.find_node_max_in_flight == 0 - { - return Err(AppError::Config("网络速率和并发上限必须大于零".to_owned())); - } - if self.dht.sample_new_node_percent > 100 { - return Err(AppError::Config( - "sample_new_node_percent 必须在 0 到 100 之间".to_owned(), - )); - } - Ok(()) - } -} - -impl Default for AppConfig { - fn default() -> Self { - Self { - data_dir: PathBuf::from("data"), - content_filter_file: PathBuf::from("content-filters.toml"), - persistence_queue_capacity: 8_192, - stats_interval_secs: 10, - run_duration_secs: None, - index_batch_size: 1_024, - index_interval_millis: 5_000, - metadata_limits: MetadataLimitsConfig::default(), - dht: DhtConfig::default(), - disk_guard: DiskGuardConfig::default(), - backup: BackupConfig::default(), - logging: LoggingConfig::default(), - http: HttpConfig::default(), - verification: VerificationConfig::default(), - } - } -} - -impl Default for MetadataLimitsConfig { - fn default() -> Self { - Self { - max_metadata_bytes: 10 * 1024 * 1024, - max_files: 20_000, - max_name_bytes: 1_024, - max_path_bytes: 4_096, - max_path_depth: 64, - } - } -} - -impl Default for DhtConfig { - fn default() -> Self { - Self { - port: 12_313, - netmode: NetworkMode::Ipv4Only, - hash_queue_capacity: 20_000, - max_outbound_queries_per_second: 1_000, - outbound_query_burst: 200, - metadata_timeout_secs: 6, - metadata_queue_capacity: 20_000, - metadata_workers: 400, - metadata_connects_per_second: 400, - sample_queries_per_second: 60, - sample_max_in_flight: 100, - sample_new_node_percent: 50, - sample_candidate_queue_capacity: 8_192, - sample_fallback_to_iterative: false, - peer_lookups_per_second: 200, - peer_lookup_max_active: 200, - find_node_queries_per_second: 10, - find_node_max_in_flight: 100, - new_destinations_per_minute: 12_000, - } - } -} - -impl Default for HttpConfig { - fn default() -> Self { - Self { - listen: SocketAddr::from(([127, 0, 0, 1], 8080)), - web_dir: PathBuf::from("web/dist"), - } - } -} - -impl Default for DiskGuardConfig { - fn default() -> Self { - Self { - enabled: true, - check_interval_secs: 10, - minimum_free_bytes: 5 * 1024 * 1024 * 1024, - resume_free_bytes: 6 * 1024 * 1024 * 1024, - } - } -} - -impl Default for LoggingConfig { - fn default() -> Self { - Self { - directory: PathBuf::from("data/logs"), - file_enabled: true, - console_enabled: false, - rotation: LogRotation::Daily, - retain_files: 7, - file_prefix: "dht-search".to_owned(), - } - } -} - -impl Default for BackupConfig { - fn default() -> Self { - Self { - enabled: true, - directory: PathBuf::from("data/backups"), - interval_secs: 6 * 60 * 60, - retain_checkpoints: 3, - create_on_start: true, - } - } -} - -impl Default for VerificationConfig { - fn default() -> Self { - Self { - enabled: true, - queue_capacity: 10_000, - max_active: 8, - max_peer_attempts: 3, - lease_secs: 60, - poll_interval_millis: 250, - } - } -} - -impl From for NetMode { - fn from(value: NetworkMode) -> Self { - match value { - NetworkMode::Ipv4Only => Self::Ipv4Only, - NetworkMode::Ipv6Only => Self::Ipv6Only, - NetworkMode::DualStack => Self::DualStack, - } - } -} - -fn absolute_path(path: &PathBuf) -> Result { - if path.is_absolute() { - Ok(path.clone()) - } else { - Ok(std::env::current_dir()?.join(path)) - } -} - -fn normalize_absolute(path: PathBuf) -> Result { - if path.is_absolute() { - Ok(path) - } else { - absolute_path(&path) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn defaults_produce_valid_dht_options() { - let config = AppConfig::default(); - config.validate().unwrap(); - let options = config.dht_options(); - assert_eq!(options.port, 12_313); - assert_eq!(options.metadata.max_worker_count, 400); - assert_eq!(options.metadata.max_connects_per_second, 400); - assert_eq!(options.metadata.max_metadata_size_bytes, 10 * 1024 * 1024); - assert_eq!(options.max_outbound_queries_per_second, 1_000); - assert_eq!(options.sample_infohashes.max_queries_per_second, 60); - assert_eq!(options.sample_infohashes.max_in_flight, 100); - assert_eq!(options.sample_infohashes.new_node_sample_percent, 50); - assert_eq!(options.sample_infohashes.candidate_queue_capacity, 8_192); - assert!(!options.sample_infohashes.fallback_to_iterative); - assert_eq!(options.peer_lookup.max_lookups_per_second, 200); - assert_eq!(options.peer_lookup.max_active_lookups, 200); - assert_eq!(options.crawl.rate_limit.max_find_node_rate_per_sec, 10); - assert_eq!( - options.crawl.rate_limit.max_new_destinations_per_minute, - 12_000 - ); - } - - #[test] - fn relative_data_directory_is_resolved_from_config_file() { - let directory = TempDir::new().unwrap(); - let config_path = directory.path().join("service.toml"); - fs::write(&config_path, "data_dir = 'state'").unwrap(); - let startup = Cli { - config: config_path, - data_dir: None, - run_duration_secs: None, - restore_checkpoint: None, - } - .load() - .unwrap(); - let config = startup.app; - assert_eq!(config.data_dir, directory.path().join("state")); - assert_eq!( - config.content_filter_file, - directory.path().join("content-filters.toml") - ); - assert_eq!(config.logging.directory, directory.path().join("data/logs")); - assert_eq!( - config.backup.directory, - directory.path().join("data/backups") - ); - assert_eq!(config.http.web_dir, directory.path().join("web/dist")); - } - - #[test] - fn unknown_config_field_is_rejected() { - let directory = TempDir::new().unwrap(); - let config_path = directory.path().join("service.toml"); - fs::write(&config_path, "unknown = true").unwrap(); - let error = Cli { - config: config_path, - data_dir: None, - run_duration_secs: None, - restore_checkpoint: None, - } - .load() - .unwrap_err(); - assert!(matches!(error, AppError::Toml(_))); - } - - #[test] - fn zero_metadata_limit_is_rejected() { - let mut config = AppConfig::default(); - config.metadata_limits.max_files = 0; - assert!(matches!(config.validate(), Err(AppError::Config(_)))); - } - - #[test] - fn invalid_new_node_sample_percent_is_rejected() { - let mut config = AppConfig::default(); - config.dht.sample_new_node_percent = 101; - assert!(matches!(config.validate(), Err(AppError::Config(_)))); - } - - #[test] - fn disk_resume_threshold_must_exceed_minimum() { - let mut config = AppConfig::default(); - config.disk_guard.resume_free_bytes = config.disk_guard.minimum_free_bytes; - assert!(matches!(config.validate(), Err(AppError::Config(_)))); - } - - #[test] - fn logging_requires_at_least_one_output() { - let mut config = AppConfig::default(); - config.logging.file_enabled = false; - config.logging.console_enabled = false; - assert!(matches!(config.validate(), Err(AppError::Config(_)))); - } - - #[test] - fn logging_prefix_cannot_escape_the_log_directory() { - let mut config = AppConfig::default(); - config.logging.file_prefix = "../service".into(); - assert!(matches!(config.validate(), Err(AppError::Config(_)))); - } - - #[test] - fn backup_directory_cannot_be_inside_rocksdb() { - let mut config = AppConfig::default(); - config.backup.directory = config.data_dir.join("rocksdb/checkpoints"); - assert!(matches!(config.validate(), Err(AppError::Config(_)))); - } -} diff --git a/dht-search/src/lib.rs b/dht-search/src/lib.rs deleted file mode 100644 index fd4913c..0000000 --- a/dht-search/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -// 负责导出可测试可组合的领域模型和持久化能力 - -pub mod domain; -pub mod search; -pub mod storage; diff --git a/scripts/run.bat b/scripts/run.bat index ec19677..d88ca51 100644 --- a/scripts/run.bat +++ b/scripts/run.bat @@ -2,7 +2,7 @@ setlocal for %%I in ("%~dp0..") do set "PROJECT_ROOT=%%~fI" -set "WEB_DIR=%PROJECT_ROOT%\web" +set "WEB_DIR=%PROJECT_ROOT%\src\web" set "LIBCLANG_PATH=%PROJECT_ROOT%\.tools\libclang\clang\native" where cargo >nul 2>nul @@ -29,6 +29,16 @@ if not exist "%LIBCLANG_PATH%\libclang.dll" ( exit /b 1 ) +if not exist "%PROJECT_ROOT%\dht-search.toml" ( + echo [INFO] Creating local configuration from template + copy /Y "%PROJECT_ROOT%\dht-search.example.toml" "%PROJECT_ROOT%\dht-search.toml" >nul + if errorlevel 1 ( + echo [ERROR] Failed to create dht-search.toml + pause + exit /b 1 + ) +) + if not exist "%WEB_DIR%\node_modules" ( echo [INFO] Installing Web dependencies pushd "%WEB_DIR%" @@ -60,7 +70,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command ^ " }" ^ "}" ^ "try {" ^ - " $backend = Start-Process -FilePath 'cargo' -ArgumentList @('run','-p','dht-search','--','--config','dht-search.example.toml') -WorkingDirectory $env:PROJECT_ROOT -NoNewWindow -PassThru;" ^ + " $backend = Start-Process -FilePath 'cargo' -ArgumentList @('run','-p','dht-search','--bin','dht-search','--','--config','dht-search.toml') -WorkingDirectory $env:PROJECT_ROOT -NoNewWindow -PassThru;" ^ " $web = Start-Process -FilePath 'bun' -ArgumentList @('--bun','run','dev','--','--host','127.0.0.1') -WorkingDirectory $env:WEB_DIR -NoNewWindow -PassThru;" ^ " while (-not $backend.HasExited -and -not $web.HasExited) { Start-Sleep -Milliseconds 250 }" ^ "} finally {" ^ diff --git a/dht-crawler/CHANGELOG.md b/src/crawler/CHANGELOG.md similarity index 100% rename from dht-crawler/CHANGELOG.md rename to src/crawler/CHANGELOG.md diff --git a/dht-crawler/Cargo.toml b/src/crawler/Cargo.toml similarity index 100% rename from dht-crawler/Cargo.toml rename to src/crawler/Cargo.toml diff --git a/dht-crawler/README.md b/src/crawler/README.md similarity index 99% rename from dht-crawler/README.md rename to src/crawler/README.md index d60bcf6..0c5be0f 100644 --- a/dht-crawler/README.md +++ b/src/crawler/README.md @@ -2,7 +2,7 @@ [![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)](../LICENSE) +[![License](https://img.shields.io/crates/l/dht-crawler.svg)](../../LICENSE) 基于 Rust 和 Tokio 的 BitTorrent DHT 爬虫库。它参与 BEP-5 DHT 网络,通过 BEP-51 `sample_infohashes` 主动发现 InfoHash,也接收 `announce_peer`,并通过 BEP-9 diff --git a/dht-crawler/docs/metrics.md b/src/crawler/docs/metrics.md similarity index 100% rename from dht-crawler/docs/metrics.md rename to src/crawler/docs/metrics.md diff --git a/dht-crawler/examples/main.rs b/src/crawler/examples/main.rs similarity index 100% rename from dht-crawler/examples/main.rs rename to src/crawler/examples/main.rs diff --git a/dht-crawler/src/addr.rs b/src/crawler/src/addr.rs similarity index 100% rename from dht-crawler/src/addr.rs rename to src/crawler/src/addr.rs diff --git a/dht-crawler/src/bootstrap.rs b/src/crawler/src/bootstrap.rs similarity index 100% rename from dht-crawler/src/bootstrap.rs rename to src/crawler/src/bootstrap.rs diff --git a/dht-crawler/src/budget.rs b/src/crawler/src/budget.rs similarity index 100% rename from dht-crawler/src/budget.rs rename to src/crawler/src/budget.rs diff --git a/dht-crawler/src/crawl_config.rs b/src/crawler/src/crawl_config.rs similarity index 100% rename from dht-crawler/src/crawl_config.rs rename to src/crawler/src/crawl_config.rs diff --git a/dht-crawler/src/crawl_engine.rs b/src/crawler/src/crawl_engine.rs similarity index 100% rename from dht-crawler/src/crawl_engine.rs rename to src/crawler/src/crawl_engine.rs diff --git a/dht-crawler/src/error.rs b/src/crawler/src/error.rs similarity index 100% rename from dht-crawler/src/error.rs rename to src/crawler/src/error.rs diff --git a/dht-crawler/src/krpc.rs b/src/crawler/src/krpc.rs similarity index 100% rename from dht-crawler/src/krpc.rs rename to src/crawler/src/krpc.rs diff --git a/dht-crawler/src/lib.rs b/src/crawler/src/lib.rs similarity index 86% rename from dht-crawler/src/lib.rs rename to src/crawler/src/lib.rs index 7687877..ef5751f 100644 --- a/dht-crawler/src/lib.rs +++ b/src/crawler/src/lib.rs @@ -38,12 +38,12 @@ pub use runtime_stats::{ DhtObservabilitySnapshot, DhtRuntimeSnapshot, DhtRuntimeStats, FixedHistogramSnapshot, }; pub use scheduler::{MetadataScheduler, MetadataSchedulerCallbacks, MetadataSchedulerLimits}; -pub use server::{DHTServer, HashDiscovered}; +pub use server::DHTServer; pub use types::{ - BootstrapOptions, CrawlOptions, DHTOptions, DiscoverySource, FileInfo, MetadataFetchCompletion, - MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PeerLookupOptions, - PeerLookupResult, PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, - TargetOptions, TorrentInfo, + BootstrapOptions, CrawlOptions, DHTOptions, DiscoverySource, FileInfo, HashDiscovered, + MetadataFetchCompletion, MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, + PeerLookupOptions, PeerLookupResult, PoolOptions, RateLimitOptions, SampleInfohashesOptions, + SchedulerOptions, TargetOptions, TorrentInfo, }; /// Common server, configuration and callback payload imports. @@ -55,7 +55,7 @@ pub mod prelude { }; pub use crate::server::DHTServer; pub use crate::types::{ - BootstrapOptions, CrawlOptions, DHTOptions, DiscoverySource, FileInfo, + BootstrapOptions, CrawlOptions, DHTOptions, DiscoverySource, FileInfo, HashDiscovered, MetadataFetchCompletion, MetadataFetchCompletionStatus, MetadataOptions, NetMode, NodeTuple, PeerLookupOptions, PeerLookupResult, PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, TargetOptions, TorrentInfo, diff --git a/dht-crawler/src/metadata.rs b/src/crawler/src/metadata.rs similarity index 100% rename from dht-crawler/src/metadata.rs rename to src/crawler/src/metadata.rs diff --git a/dht-crawler/src/node_id.rs b/src/crawler/src/node_id.rs similarity index 100% rename from dht-crawler/src/node_id.rs rename to src/crawler/src/node_id.rs diff --git a/dht-crawler/src/node_pool.rs b/src/crawler/src/node_pool.rs similarity index 100% rename from dht-crawler/src/node_pool.rs rename to src/crawler/src/node_pool.rs diff --git a/dht-crawler/src/peer_lookup.rs b/src/crawler/src/peer_lookup.rs similarity index 99% rename from dht-crawler/src/peer_lookup.rs rename to src/crawler/src/peer_lookup.rs index e4e2287..1c2da73 100644 --- a/dht-crawler/src/peer_lookup.rs +++ b/src/crawler/src/peer_lookup.rs @@ -6,8 +6,9 @@ use crate::node_id::TransactionId; use crate::protocol::DhtResponse; use crate::routing_snapshot::{RoutingSnapshot, xor_distance_cmp}; use crate::runtime_stats::DhtRuntimeStats; -use crate::server::HashDiscovered; -use crate::types::{DiscoverySource, NetMode, NodeTuple, PeerLookupOptions, PeerLookupResult}; +use crate::types::{ + DiscoverySource, HashDiscovered, NetMode, NodeTuple, PeerLookupOptions, PeerLookupResult, +}; use ahash::{AHashMap, AHashSet}; use arc_swap::ArcSwap; use bytes::BytesMut; diff --git a/dht-crawler/src/protocol.rs b/src/crawler/src/protocol.rs similarity index 100% rename from dht-crawler/src/protocol.rs rename to src/crawler/src/protocol.rs diff --git a/dht-crawler/src/response_limiter.rs b/src/crawler/src/response_limiter.rs similarity index 100% rename from dht-crawler/src/response_limiter.rs rename to src/crawler/src/response_limiter.rs diff --git a/dht-crawler/src/routing_snapshot.rs b/src/crawler/src/routing_snapshot.rs similarity index 100% rename from dht-crawler/src/routing_snapshot.rs rename to src/crawler/src/routing_snapshot.rs diff --git a/dht-crawler/src/runtime_stats.rs b/src/crawler/src/runtime_stats.rs similarity index 100% rename from dht-crawler/src/runtime_stats.rs rename to src/crawler/src/runtime_stats.rs diff --git a/dht-crawler/src/sample_infohashes.rs b/src/crawler/src/sample_infohashes.rs similarity index 100% rename from dht-crawler/src/sample_infohashes.rs rename to src/crawler/src/sample_infohashes.rs diff --git a/dht-crawler/src/scheduler.rs b/src/crawler/src/scheduler.rs similarity index 99% rename from dht-crawler/src/scheduler.rs rename to src/crawler/src/scheduler.rs index d695487..ca20e44 100644 --- a/dht-crawler/src/scheduler.rs +++ b/src/crawler/src/scheduler.rs @@ -5,9 +5,9 @@ use crate::peer_lookup::PeerLookupRequest; #[cfg(test)] use crate::runtime_stats::DhtRuntimeLimits; use crate::runtime_stats::DhtRuntimeStats; -use crate::server::HashDiscovered; use crate::types::{ - DiscoverySource, MetadataFetchCompletion, MetadataFetchCompletionStatus, TorrentInfo, + DiscoverySource, HashDiscovered, MetadataFetchCompletion, MetadataFetchCompletionStatus, + TorrentInfo, }; use arc_swap::ArcSwapOption; #[cfg(feature = "metrics")] diff --git a/dht-crawler/src/server.rs b/src/crawler/src/server.rs similarity index 98% rename from dht-crawler/src/server.rs rename to src/crawler/src/server.rs index 9e59b57..273cc9e 100644 --- a/dht-crawler/src/server.rs +++ b/src/crawler/src/server.rs @@ -27,7 +27,8 @@ use crate::scheduler::{ TorrentAckCallback, }; use crate::types::{ - DHTOptions, DiscoverySource, MetadataFetchCompletion, NetMode, NodeTuple, TorrentInfo, + DHTOptions, DiscoverySource, HashDiscovered, MetadataFetchCompletion, NetMode, NodeTuple, + TorrentInfo, }; use crate::udp_buffer::UdpBufferPool; use crate::udp_ingress::{WorkerHandle, spawn_udp_listener}; @@ -60,19 +61,6 @@ struct QueryResponse<'a> { target_id: Option<&'a [u8]>, } -#[derive(Debug, Clone)] -/// InfoHash and announcing Peer submitted to the Metadata scheduler. -pub struct HashDiscovered { - /// Lowercase hexadecimal InfoHash. - pub info_hash: String, - /// Peer endpoint derived from announce `port`/`implied_port`. - pub peer_addr: SocketAddr, - /// Network path that supplied this Peer candidate. - pub source: DiscoverySource, - /// Monotonic discovery time used for freshness and queue ordering. - pub discovered_at: std::time::Instant, -} - #[derive(Clone)] /// Cloneable BEP-5 server handle and primary crate entry point. pub struct DHTServer { diff --git a/dht-crawler/src/types.rs b/src/crawler/src/types.rs similarity index 96% rename from dht-crawler/src/types.rs rename to src/crawler/src/types.rs index 906c4b0..4fa52ba 100644 --- a/dht-crawler/src/types.rs +++ b/src/crawler/src/types.rs @@ -174,6 +174,19 @@ pub enum DiscoverySource { ActiveLookup, } +#[derive(Debug, Clone)] +/// InfoHash and announcing Peer submitted to the Metadata scheduler. +pub struct HashDiscovered { + /// Lowercase hexadecimal InfoHash. + pub info_hash: String, + /// Peer endpoint derived from announce `port`/`implied_port`. + pub peer_addr: SocketAddr, + /// Network path that supplied this Peer candidate. + pub source: DiscoverySource, + /// Monotonic discovery time used for freshness and queue ordering. + pub discovered_at: std::time::Instant, +} + #[derive(Debug, Clone, PartialEq, Eq)] /// Terminal result of one bounded active `get_peers` lookup. pub struct PeerLookupResult { diff --git a/dht-crawler/src/udp_buffer.rs b/src/crawler/src/udp_buffer.rs similarity index 100% rename from dht-crawler/src/udp_buffer.rs rename to src/crawler/src/udp_buffer.rs diff --git a/dht-crawler/src/udp_ingress.rs b/src/crawler/src/udp_ingress.rs similarity index 100% rename from dht-crawler/src/udp_ingress.rs rename to src/crawler/src/udp_ingress.rs diff --git a/dht-search/Cargo.toml b/src/search/Cargo.toml similarity index 70% rename from dht-search/Cargo.toml rename to src/search/Cargo.toml index b90b943..ea2d30a 100644 --- a/dht-search/Cargo.toml +++ b/src/search/Cargo.toml @@ -11,19 +11,20 @@ publish = false [features] default = ["rocksdb-storage"] -rocksdb-storage = ["dep:rocksdb"] +rocksdb-storage = ["dep:rocksdb", "dep:rusqlite"] [dependencies] axum = "0.8.9" blake3 = "1.8.5" clap = { version = "4.5", features = ["derive"] } -dht-crawler = { path = "../dht-crawler", features = ["metrics"] } +dht-crawler = { path = "../crawler", features = ["metrics"] } dunce = "1.0" hex = "0.4" fs2 = "0.4" rocksdb = { version = "0.24.0", default-features = false, features = ["bindgen-runtime", "lz4"], optional = true } rmp-serde = "1.3" regex = "1.12" +rusqlite = { version = "0.40.2", features = ["bundled"], optional = true } serde.workspace = true serde_json = "1.0" tantivy = "0.26.1" @@ -42,7 +43,15 @@ tempfile = "3.27" tower = { version = "0.5", features = ["util"] } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61.2", features = ["Win32_System_ProcessStatus", "Win32_System_Threading"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[[bin]] +name = "dht-search" +path = "src/main.rs" +required-features = ["rocksdb-storage"] [[bin]] name = "dht-benchmark" diff --git a/dht-search/README.md b/src/search/README.md similarity index 86% rename from dht-search/README.md rename to src/search/README.md index 7d9964b..d1e5b52 100644 --- a/dht-search/README.md +++ b/src/search/README.md @@ -36,7 +36,7 @@ cargo run -p dht-search -- --data-dir D:\data\dht-search --run-duration-secs 360 不设置 `run-duration-secs` 时服务持续运行直到收到 Ctrl+C SIGINT 或 SIGTERM -生产 Web 页面需要先在 `web` 目录执行 `bun run build` Axum 会从 `http.web_dir` 提供构建结果 +生产 Web 页面需要先在 `src/web` 目录执行 `bun run build` Axum 会从 `http.web_dir` 提供构建结果 ### 当前运行模板网络配置 @@ -69,7 +69,7 @@ Metadata 下载和可用性握手共用 `metadata_connects_per_second` 预算不 |---|---:|---| | `verification.enabled` | `true` | 是否启用按需可用性验证 | | `verification.queue_capacity` | `10000` | 持久化验证队列容量 | -| `verification.max_active` | `2` | 同时验证的种子数量 | +| `verification.max_active` | `8` | 同时验证的种子数量 | | `verification.max_peer_attempts` | `3` | 每个种子最多握手的 Peer 数量 | | `verification.lease_secs` | `60` | 异常退出后验证任务重新可领取的租约时间 | | `verification.poll_interval_millis` | `250` | 持久化队列轮询间隔 | @@ -146,6 +146,33 @@ target\release\dht-search.exe ` 确认恢复数据无误后可以人工删除 `rocksdb.pre-restore-*` 释放空间 不要在服务运行时移动或删除这些目录 +### 运行诊断历史 + +应用把可删除的运行资源采样写入独立的 `data/diagnostics.sqlite3` RocksDB 仍然是唯一业务权威数据源 删除诊断数据库不会影响种子数据搜索索引或恢复 + +| 配置项 | 默认值 | 作用 | +|---|---:|---| +| `diagnostics.enabled` | `true` | 是否采集并保存运行诊断历史 | +| `diagnostics.database` | `data/diagnostics.sqlite3` | 独立 SQLite 数据库路径 | +| `diagnostics.sample_interval_secs` | `10` | 实时资源采样间隔 | +| `diagnostics.raw_retention_hours` | `24` | 原始采样保留时间 | +| `diagnostics.minute_retention_days` | `30` | 每分钟快照保留时间 | +| `diagnostics.queue_capacity` | `128` | SQLite writer 有界队列容量 | + +SQLite 使用 WAL 和单独 writer 线程 原始采样超过 24 小时自动删除 同一分钟只保留最新快照且超过 30 天自动删除 写入失败不会停止采集和搜索核心服务 + +诊断快照包含进程内存 CPU 时间线程句柄 DHT 流量队列深度 RocksDB Block Cache MemTable Compaction 和 SST 状态 Tantivy Writer 预算和提交耗时以及 HTTP 并发错误分类平均 P95 和最大延迟 + +### 配置管理 + +配置文件只是强类型配置 DTO 的 TOML 持久化形式 服务通过 `GET /config` 返回当前文件配置和稳定修订号 通过 `PUT /config` 接受完整 DTO + +更新前会执行与启动时相同的完整校验 保存时先在同目录写入并同步临时文件再原子替换目标文件 旧修订号返回 `409 Conflict` 防止多个页面互相覆盖 + +当前版本不在线修改正在运行的 DHT 存储索引和监听器 保存成功后返回 `restart_required = true` 重启服务后统一生效 命令行覆盖字段也会单独返回并继续优先于文件配置 + +通过 Web 保存会按 DTO 重新生成 TOML 原有手写注释不会保留 管理接口默认随 HTTP 服务提供 因此生产部署不应把 `/config` 暴露到不受信任的公网入口 + ### Metadata 安全限制 应用会在 Metadata 下载和进入 RocksDB 前执行两层资源与结构校验 @@ -225,6 +252,11 @@ LimitNOFILE=65536 GET /health GET /ready GET /stats +GET /diagnostics/current +GET /diagnostics/history?range_secs=3600&resolution=raw +GET /diagnostics/history?range_secs=86400&resolution=minute +GET /config +PUT /config GET /search?q=ubuntu&offset=0&limit=20 GET /search?q=%2A.iso GET /search?q=&min_size=1048576&max_size=10737418240&extension=mkv @@ -254,6 +286,8 @@ GET /torrents/{infohash}?file_offset=0&file_limit=100 详情文件列表默认返回 100 条且单次最多 200 条 使用 `file_offset` 翻页避免超大种子一次向浏览器返回全部文件 +生产 Web 页面使用 `/` `/system` 和 `/settings` 三个路由 分别提供搜索运行诊断和配置管理 API 路径继续保持独立避免单页回退冲突 + ## 数据恢复 RocksDB 是权威数据源而 Tantivy 是可重建索引 @@ -262,7 +296,7 @@ RocksDB 是权威数据源而 Tantivy 是可重建索引 项目当前处于开发阶段 持久化结构变化时直接清理测试数据重新采集 不维护旧测试数据库兼容层 -正常退出会先停止 DHT 再排空持久化队列提交剩余索引最后关闭 HTTP 服务 +正常退出会先停止 DHT 和诊断采样 再排空持久化队列提交剩余索引最后关闭 HTTP 服务 ## 规模基准 diff --git a/dht-search/src/api/handlers.rs b/src/search/src/api/handlers.rs similarity index 81% rename from dht-search/src/api/handlers.rs rename to src/search/src/api/handlers.rs index 466eb4d..f51c0a0 100644 --- a/dht-search/src/api/handlers.rs +++ b/src/search/src/api/handlers.rs @@ -1,27 +1,32 @@ // 负责处理搜索详情统计和健康检查请求 -use std::str::FromStr; +use std::{ + str::FromStr, + time::{SystemTime, UNIX_EPOCH}, +}; +use crate::{ + domain::{InfoHash, MetadataRejectionReason}, + search::{SearchMode, SearchOptions, SearchPage, SearchSort}, + storage::VerificationPriority, +}; use axum::{ Json, extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; -use dht_search::{ - domain::{InfoHash, MetadataRejectionReason}, - search::{SearchMode, SearchOptions, SearchPage, SearchSort}, - storage::VerificationPriority, -}; use super::{ ApiState, - request::{ContentVariantsRequest, SearchRequest, TorrentRequest}, + request::{ContentVariantsRequest, DiagnosticHistoryRequest, SearchRequest, TorrentRequest}, response::{ ContentVariantsResponse, ErrorResponse, StatsResponse, StatusResponse, TorrentResponse, TorrentVariantResponse, }, }; +use crate::config::{ConfigServiceError, ConfigSnapshot, ConfigUpdateRequest}; +use crate::diagnostics::{CurrentDiagnosticsResponse, DiagnosticHistory, HistoryResolution}; pub(crate) async fn health() -> Json { Json(StatusResponse { status: "ok" }) @@ -37,12 +42,20 @@ pub(crate) async fn stats(State(state): State) -> Json let persistence = state.persistence.snapshot(); let disk = state.disk_guard.snapshot(); let backup = state.backup_stats.snapshot(); + let http = state.http_stats.snapshot(); let filtered = persistence.filtered; let verification = state .verification .as_ref() .map(|ingress| ingress.stats().snapshot()); Json(StatsResponse { + http_active_requests: http.active_requests, + http_requests: http.requests, + http_client_errors: http.client_errors, + http_server_errors: http.server_errors, + http_latency_average_micros: http.latency_average_micros, + http_latency_p95_millis: http.latency_p95_millis, + http_latency_max_millis: http.latency_max_millis, backup_created: backup.created, backup_failed: backup.failed, backup_skipped: backup.skipped, @@ -128,6 +141,58 @@ pub(crate) async fn stats(State(state): State) -> Json }) } +pub(crate) async fn diagnostics_current( + State(state): State, +) -> Json { + Json(state.diagnostics.current()) +} + +pub(crate) async fn diagnostics_history( + State(state): State, + Query(request): Query, +) -> Result, ApiError> { + const MAX_RANGE_SECS: u64 = 31 * 86_400; + if request.range_secs == 0 || request.range_secs > MAX_RANGE_SECS { + return Err(ApiError::bad_request("range_secs 必须在 1 到 2678400 之间")); + } + let resolution = match request.resolution.as_str() { + "raw" => HistoryResolution::Raw, + "minute" => HistoryResolution::Minute, + _ => return Err(ApiError::bad_request("resolution 只支持 raw 或 minute")), + }; + let to = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let from = to.saturating_sub(request.range_secs); + let diagnostics = state.diagnostics.clone(); + let history = tokio::task::spawn_blocking(move || diagnostics.history(resolution, from, to)) + .await + .map_err(|error| ApiError::internal(format!("诊断历史查询任务失败: {error}")))? + .map_err(|error| ApiError::internal(error.to_string()))?; + Ok(Json(history)) +} + +pub(crate) async fn config_snapshot(State(state): State) -> Json { + Json(state.config.snapshot()) +} + +pub(crate) async fn config_update( + State(state): State, + Json(request): Json, +) -> Result, ApiError> { + let config = state.config.clone(); + let snapshot = tokio::task::spawn_blocking(move || config.update(request)) + .await + .map_err(|error| ApiError::internal(format!("配置保存任务失败: {error}")))? + .map_err(|error| match error { + ConfigServiceError::Conflict => ApiError::conflict(error.to_string()), + ConfigServiceError::Validation(_) => ApiError::bad_request(error.to_string()), + ConfigServiceError::Persistence(_) => ApiError::internal(error.to_string()), + })?; + Ok(Json(snapshot)) +} + pub(crate) async fn search( State(state): State, Query(request): Query, @@ -315,6 +380,13 @@ impl ApiError { } } + fn conflict(message: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + message: message.into(), + } + } + fn internal(message: impl Into) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, diff --git a/dht-search/src/api/mod.rs b/src/search/src/api/mod.rs similarity index 67% rename from dht-search/src/api/mod.rs rename to src/search/src/api/mod.rs index d45864a..4931671 100644 --- a/dht-search/src/api/mod.rs +++ b/src/search/src/api/mod.rs @@ -6,14 +6,24 @@ mod response; use std::{net::SocketAddr, path::PathBuf, sync::Arc}; -use axum::{Router, routing::get}; +use crate::{search::SearchEngine, storage::TorrentRepository}; +use axum::{ + Router, + extract::{Request, State}, + middleware::{self, Next}, + response::Response, + routing::get, +}; use dht_crawler::DhtRuntimeStats; -use dht_search::{search::SearchEngine, storage::TorrentRepository}; use tokio_util::sync::CancellationToken; use tower_http::services::{ServeDir, ServeFile}; use crate::{ - backup::BackupStats, crawler::pipeline::PersistenceIngress, disk_guard::DiskGuard, + backup::BackupStats, + config::ConfigService, + crawler::pipeline::PersistenceIngress, + diagnostics::{DiagnosticsHandle, HttpStats}, + disk_guard::DiskGuard, verification::VerificationIngress, }; @@ -26,6 +36,9 @@ pub(crate) struct ApiState { pub(crate) verification: Option, pub(crate) disk_guard: DiskGuard, pub(crate) backup_stats: BackupStats, + pub(crate) diagnostics: DiagnosticsHandle, + pub(crate) config: ConfigService, + pub(crate) http_stats: HttpStats, } pub(crate) async fn serve( @@ -44,36 +57,54 @@ pub(crate) async fn serve( fn router(state: ApiState, web_dir: PathBuf) -> Router { let index = web_dir.join("index.html"); + let http_stats = state.http_stats.clone(); Router::new() .route("/health", get(handlers::health)) .route("/ready", get(handlers::ready)) .route("/stats", get(handlers::stats)) + .route("/diagnostics/current", get(handlers::diagnostics_current)) + .route("/diagnostics/history", get(handlers::diagnostics_history)) + .route( + "/config", + get(handlers::config_snapshot).put(handlers::config_update), + ) .route("/search", get(handlers::search)) .route("/contents/{content_key}", get(handlers::content_variants)) .route("/torrents/{info_hash}", get(handlers::torrent)) - .with_state(state) .fallback_service(ServeDir::new(web_dir).fallback(ServeFile::new(index))) + .layer(middleware::from_fn_with_state(http_stats, observe_http)) + .with_state(state) +} + +async fn observe_http(State(stats): State, request: Request, next: Next) -> Response { + let timer = stats.begin(); + let response = next.run(request).await; + timer.finish(response.status().as_u16()); + response } #[cfg(test)] mod tests { use std::sync::Arc; + use crate::{ + domain::{InfoHash, MetadataCandidate, MetadataLimits, TorrentFile, TorrentRecord}, + search::SearchEngine, + storage::{RocksTorrentRepository, TorrentRepository}, + }; use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode}, }; - use dht_crawler::{DhtRuntimeStats, FileInfo, TorrentInfo}; - use dht_search::{ - domain::{InfoHash, MetadataLimits, TorrentRecord}, - search::SearchEngine, - storage::{RocksTorrentRepository, TorrentRepository}, - }; + use dht_crawler::DhtRuntimeStats; use tempfile::TempDir; use tower::ServiceExt; use crate::{ - config::DiskGuardConfig, crawler::pipeline::PersistencePipeline, disk_guard::DiskGuard, + config::{AppConfigDto, ConfigService, DiskGuardConfig, TomlConfigStore}, + crawler::pipeline::PersistencePipeline, + diagnostics::DiagnosticsRuntime, + disk_guard::DiskGuard, verification::VerificationIngress, }; @@ -84,13 +115,12 @@ mod tests { let directory = TempDir::new().unwrap(); let repository = Arc::new(RocksTorrentRepository::open(directory.path().join("rocksdb")).unwrap()); - let mut record = TorrentRecord::try_from(TorrentInfo { + let mut record = TorrentRecord::try_from(MetadataCandidate { info_hash: "0101010101010101010101010101010101010101".into(), - magnet_link: String::new(), name: "Example Movie".into(), total_size: 205, files: (0..205) - .map(|index| FileInfo { + .map(|index| TorrentFile { path: if index == 0 { "movie.mkv".into() } else { @@ -100,11 +130,11 @@ mod tests { }) .collect(), piece_length: 16_384, - peers: vec!["127.0.0.1:6881".into()], + source_peers: vec!["127.0.0.1:6881".into()], timestamp: 10, }) .unwrap(); - record.availability = dht_search::domain::Availability::default(); + record.availability = crate::domain::Availability::default(); repository.upsert(record.clone()).unwrap(); let mut variant = record.clone(); variant.info_hash = InfoHash::from_bytes([2; 20]); @@ -136,18 +166,28 @@ mod tests { verification: Some(verification), disk_guard, backup_stats: BackupStats::default(), + diagnostics: DiagnosticsRuntime::disabled().handle(), + config: ConfigService::new( + Arc::new(TomlConfigStore::new(directory.path().join("service.toml"))), + AppConfigDto::default(), + Vec::new(), + ) + .unwrap(), + http_stats: HttpStats::default(), }, web_dir, ); - let response = app - .clone() - .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!(body.as_ref(), b"
DHT Search
"); + for uri in ["/", "/system", "/settings"] { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), b"
DHT Search
"); + } let response = app .clone() @@ -168,6 +208,95 @@ mod tests { assert_eq!(json["disk_state"], "normal"); assert!(json["disk_available_bytes"].is_null()); assert_eq!(json["backup_created"], 0); + assert_eq!(json["http_active_requests"], 1); + assert!( + json["http_requests"] + .as_u64() + .is_some_and(|value| value >= 3) + ); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/diagnostics/current") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(json["status"]["enabled"], false); + assert!(json["sample"].is_null()); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/diagnostics/history?resolution=seconds") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let mut config_snapshot: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + let original_revision = config_snapshot["revision"].as_str().unwrap().to_owned(); + config_snapshot["config"]["dht"]["port"] = serde_json::json!(22_313); + let update = serde_json::json!({ + "revision": original_revision, + "config": config_snapshot["config"].clone(), + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/config") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&update).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(json["config"]["dht"]["port"], 22_313); + assert_eq!(json["restart_required"], true); + assert!(directory.path().join("service.toml").exists()); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/config") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&update).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); let response = app .clone() diff --git a/dht-search/src/api/request.rs b/src/search/src/api/request.rs similarity index 79% rename from dht-search/src/api/request.rs rename to src/search/src/api/request.rs index f19eee1..93ee677 100644 --- a/dht-search/src/api/request.rs +++ b/src/search/src/api/request.rs @@ -1,6 +1,6 @@ // 负责定义 HTTP 查询参数和输入校验模型 -use dht_search::{ +use crate::{ domain::{AvailabilityStatus, HeatLevel}, search::{SearchMode, SearchSort}, }; @@ -50,6 +50,22 @@ pub(crate) struct TorrentRequest { pub(crate) file_limit: usize, } +#[derive(Debug, Deserialize)] +pub(crate) struct DiagnosticHistoryRequest { + #[serde(default = "default_diagnostic_range")] + pub(crate) range_secs: u64, + #[serde(default = "default_diagnostic_resolution")] + pub(crate) resolution: String, +} + +fn default_diagnostic_range() -> u64 { + 3_600 +} + +fn default_diagnostic_resolution() -> String { + "raw".to_owned() +} + fn default_file_limit() -> usize { 100 } diff --git a/dht-search/src/api/response.rs b/src/search/src/api/response.rs similarity index 94% rename from dht-search/src/api/response.rs rename to src/search/src/api/response.rs index 812866c..471836e 100644 --- a/dht-search/src/api/response.rs +++ b/src/search/src/api/response.rs @@ -1,6 +1,6 @@ // 负责定义稳定的 HTTP 响应模型和领域对象转换边界 -use dht_search::domain::{Availability, Heat, TorrentFile, TorrentRecord}; +use crate::domain::{Availability, Heat, TorrentFile, TorrentRecord}; use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; @@ -16,6 +16,13 @@ pub(crate) struct ErrorResponse { #[derive(Debug, Serialize)] pub(crate) struct StatsResponse { + pub(crate) http_active_requests: u64, + pub(crate) http_requests: u64, + pub(crate) http_client_errors: u64, + pub(crate) http_server_errors: u64, + pub(crate) http_latency_average_micros: Option, + pub(crate) http_latency_p95_millis: Option, + pub(crate) http_latency_max_millis: u64, pub(crate) backup_created: u64, pub(crate) backup_failed: u64, pub(crate) backup_skipped: u64, diff --git a/dht-search/src/app.rs b/src/search/src/app.rs similarity index 78% rename from dht-search/src/app.rs rename to src/search/src/app.rs index 04bf00f..286a2be 100644 --- a/dht-search/src/app.rs +++ b/src/search/src/app.rs @@ -6,25 +6,26 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -use dht_crawler::DHTServer; -use dht_search::{ +use crate::{ domain::InfoHash, search::SearchEngine, storage::{RocksTorrentRepository, TorrentRepository}, }; +use dht_crawler::DHTServer; use tokio_util::sync::CancellationToken; use crate::{ api::{self, ApiState}, backup::{self, BackupStats}, - config::AppConfig, + config::{AppConfig, ConfigService}, crawler::pipeline::PersistencePipeline, + diagnostics::{DiagnosticSources, DiagnosticsRuntime, HttpStats}, disk_guard::{self, DiskGuard}, error::AppError, index_worker, monitor, shutdown, verification, }; -pub(crate) async fn run(config: AppConfig) -> Result<(), AppError> { +pub(crate) async fn run(config: AppConfig, config_service: ConfigService) -> Result<(), AppError> { std::fs::create_dir_all(&config.data_dir)?; let _data_lock = backup::acquire_data_lock(&config.data_dir)?; let disk_guard = DiskGuard::new(&config.disk_guard); @@ -214,6 +215,28 @@ pub(crate) async fn run(config: AppConfig) -> Result<(), AppError> { index_cancel.clone(), index_fatal_tx, )); + let http_stats = HttpStats::default(); + let diagnostics = if config.diagnostics.enabled { + match DiagnosticsRuntime::start( + config.diagnostics.clone(), + DiagnosticSources { + repository: repository.clone(), + search: search.clone(), + dht: server.runtime_stats(), + persistence: persistence.ingress.clone(), + disk_guard: disk_guard.clone(), + http: http_stats.clone(), + }, + ) { + Ok(runtime) => runtime, + Err(error) => { + tracing::warn!(%error, "运行诊断历史初始化失败 将继续提供核心服务"); + DiagnosticsRuntime::disabled() + } + } + } else { + DiagnosticsRuntime::disabled() + }; let api_cancel = CancellationToken::new(); let mut api_task = tokio::spawn(api::serve( config.http.listen, @@ -226,6 +249,9 @@ pub(crate) async fn run(config: AppConfig) -> Result<(), AppError> { verification: verification_ingress, disk_guard: disk_guard.clone(), backup_stats, + diagnostics: diagnostics.handle(), + config: config_service, + http_stats, }, api_cancel.clone(), )); @@ -269,8 +295,11 @@ pub(crate) async fn run(config: AppConfig) -> Result<(), AppError> { } }; + let mut shutdown_error = None; + diagnostics.request_shutdown(); verification_cancel.cancel(); backup_cancel.cancel(); + server.shutdown(); if let Some(task) = backup_task { let _ = task.await; } @@ -278,34 +307,76 @@ pub(crate) async fn run(config: AppConfig) -> Result<(), AppError> { if let Some(task) = verification_task { match task.await { Ok(Ok(())) => {} - Ok(Err(error)) => return Err(AppError::VerificationWorker(error)), - Err(error) => return Err(AppError::VerificationWorker(error.to_string())), + Ok(Err(error)) => { + remember_shutdown_error(&mut shutdown_error, AppError::VerificationWorker(error)); + } + Err(error) => { + remember_shutdown_error( + &mut shutdown_error, + AppError::VerificationWorker(error.to_string()), + ); + } } } - server.shutdown(); disk_cancel.cancel(); let _ = disk_task.await; monitor_cancel.cancel(); let _ = monitor.await; - persistence.close_and_join().await?; + if let Err(error) = persistence.close_and_join().await { + remember_shutdown_error(&mut shutdown_error, error); + } index_cancel.cancel(); match index_task.await { Ok(Ok(())) => {} - Ok(Err(error)) => return Err(AppError::IndexWorker(error)), - Err(error) => return Err(AppError::IndexWorker(error.to_string())), + Ok(Err(error)) => { + remember_shutdown_error(&mut shutdown_error, AppError::IndexWorker(error)); + } + Err(error) => { + remember_shutdown_error( + &mut shutdown_error, + AppError::IndexWorker(error.to_string()), + ); + } + } + if let Err(error) = diagnostics.shutdown().await { + remember_shutdown_error( + &mut shutdown_error, + AppError::Diagnostics(error.to_string()), + ); } api_cancel.cancel(); if !api_task.is_finished() { match api_task.await { Ok(Ok(())) => {} - Ok(Err(error)) => return Err(AppError::Io(error)), - Err(error) => return Err(AppError::Config(format!("HTTP 服务任务异常: {error}"))), + Ok(Err(error)) => { + remember_shutdown_error(&mut shutdown_error, AppError::Io(error)); + } + Err(error) => { + remember_shutdown_error( + &mut shutdown_error, + AppError::Config(format!("HTTP 服务任务异常: {error}")), + ); + } } } + if let Some(error) = shutdown_error { + if run_result.is_ok() { + return Err(error); + } + tracing::error!(%error, "关闭阶段发生附加错误"); + } tracing::info!("dht-search 已安全停止"); run_result } +fn remember_shutdown_error(slot: &mut Option, error: AppError) { + if slot.is_none() { + *slot = Some(error); + } else { + tracing::error!(%error, "关闭阶段发生附加错误"); + } +} + fn unix_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/dht-search/src/backup.rs b/src/search/src/backup.rs similarity index 97% rename from dht-search/src/backup.rs rename to src/search/src/backup.rs index 0bca8e5..bc34677 100644 --- a/dht-search/src/backup.rs +++ b/src/search/src/backup.rs @@ -10,7 +10,7 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; -use dht_search::storage::{CheckpointSummary, RocksTorrentRepository, StorageError}; +use crate::storage::{CheckpointSummary, RocksTorrentRepository, StorageError}; use fs2::FileExt; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -377,24 +377,26 @@ fn unix_timestamp_millis() -> u128 { #[cfg(test)] mod tests { - use dht_crawler::{FileInfo, TorrentInfo}; - use dht_search::{domain::TorrentRecord, search::SearchEngine, storage::TorrentRepository}; + use crate::{ + domain::{MetadataCandidate, TorrentFile, TorrentRecord}, + search::SearchEngine, + storage::TorrentRepository, + }; use tempfile::TempDir; use super::*; fn record(byte: u8, name: &str) -> TorrentRecord { - TorrentRecord::try_from(TorrentInfo { + TorrentRecord::try_from(MetadataCandidate { info_hash: format!("{byte:02x}").repeat(20), - magnet_link: String::new(), name: name.into(), total_size: 42, - files: vec![FileInfo { + files: vec![TorrentFile { path: format!("{name}.bin"), size: 42, }], piece_length: 16_384, - peers: Vec::new(), + source_peers: Vec::new(), timestamp: 10, }) .unwrap() diff --git a/dht-search/src/bin/dht-benchmark.rs b/src/search/src/bin/dht-benchmark.rs similarity index 100% rename from dht-search/src/bin/dht-benchmark.rs rename to src/search/src/bin/dht-benchmark.rs diff --git a/dht-search/src/bin/dht-benchmark/config.rs b/src/search/src/bin/dht-benchmark/config.rs similarity index 100% rename from dht-search/src/bin/dht-benchmark/config.rs rename to src/search/src/bin/dht-benchmark/config.rs diff --git a/dht-search/src/bin/dht-benchmark/dataset.rs b/src/search/src/bin/dht-benchmark/dataset.rs similarity index 91% rename from dht-search/src/bin/dht-benchmark/dataset.rs rename to src/search/src/bin/dht-benchmark/dataset.rs index 741205a..adcd485 100644 --- a/dht-search/src/bin/dht-benchmark/dataset.rs +++ b/src/search/src/bin/dht-benchmark/dataset.rs @@ -2,8 +2,7 @@ use std::error::Error; -use dht_crawler::{FileInfo, TorrentInfo}; -use dht_search::domain::{MetadataLimits, TorrentRecord}; +use dht_search::domain::{MetadataCandidate, MetadataLimits, TorrentFile, TorrentRecord}; pub(crate) const BASE_TIMESTAMP: u64 = 1_700_000_000; @@ -15,12 +14,12 @@ pub(crate) fn generate_record( let file_count = 1 + content_id % 4; let mut files = Vec::with_capacity(file_count); let main_size = 64 * 1024 * 1024 + (content_id as u64 % 8_192) * 1_048_576; - files.push(FileInfo { + files.push(TorrentFile { path: format!("media/category_{}/item_{content_id}.mkv", content_id % 100), size: main_size, }); for part in 1..file_count { - files.push(FileInfo { + files.push(TorrentFile { path: format!("docs/item_{content_id}/part_{part}.txt"), size: 1_024 + (content_id as u64 + part as u64) % 65_536, }); @@ -39,14 +38,13 @@ pub(crate) fn generate_record( let digest = blake3::hash(&(index as u64).to_be_bytes()); let info_hash = hex::encode(&digest.as_bytes()[..20]); TorrentRecord::try_from_with_limits( - TorrentInfo { + MetadataCandidate { info_hash, - magnet_link: String::new(), name, total_size, files, piece_length: 16_384, - peers: Vec::new(), + source_peers: Vec::new(), timestamp: BASE_TIMESTAMP.saturating_add(index as u64), }, MetadataLimits::default(), diff --git a/dht-search/src/bin/dht-benchmark/metrics.rs b/src/search/src/bin/dht-benchmark/metrics.rs similarity index 100% rename from dht-search/src/bin/dht-benchmark/metrics.rs rename to src/search/src/bin/dht-benchmark/metrics.rs diff --git a/dht-search/src/bin/dht-benchmark/report.rs b/src/search/src/bin/dht-benchmark/report.rs similarity index 100% rename from dht-search/src/bin/dht-benchmark/report.rs rename to src/search/src/bin/dht-benchmark/report.rs diff --git a/dht-search/src/bin/dht-benchmark/runner.rs b/src/search/src/bin/dht-benchmark/runner.rs similarity index 100% rename from dht-search/src/bin/dht-benchmark/runner.rs rename to src/search/src/bin/dht-benchmark/runner.rs diff --git a/dht-search/src/bin/dht-benchmark/workload.rs b/src/search/src/bin/dht-benchmark/workload.rs similarity index 100% rename from dht-search/src/bin/dht-benchmark/workload.rs rename to src/search/src/bin/dht-benchmark/workload.rs diff --git a/src/search/src/config.rs b/src/search/src/config.rs new file mode 100644 index 0000000..fd94427 --- /dev/null +++ b/src/search/src/config.rs @@ -0,0 +1,222 @@ +// 负责组合配置 DTO 持久化读取命令行覆盖和运行时解析 + +mod model; +mod runtime; +mod service; +mod store; + +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use clap::Parser; + +use crate::error::AppError; + +pub(crate) use model::{ + AppConfigDto, BackupConfig, DiagnosticsConfig, DiskGuardConfig, LogRotation, LoggingConfig, + VerificationConfig, +}; +pub(crate) use runtime::AppConfig; +pub(crate) use service::{ConfigService, ConfigServiceError, ConfigSnapshot, ConfigUpdateRequest}; +pub(crate) use store::{ConfigStore, TomlConfigStore}; + +#[derive(Debug, Parser)] +#[command(name = "dht-search", version, about = "DHT 元数据采集和搜索服务")] +pub(crate) struct Cli { + #[arg(long, default_value = "dht-search.toml")] + config: PathBuf, + #[arg(long)] + data_dir: Option, + #[arg(long)] + run_duration_secs: Option, + #[arg(long)] + restore_checkpoint: Option, +} + +#[derive(Debug)] +pub(crate) struct StartupConfig { + pub(crate) app: AppConfig, + pub(crate) config_service: ConfigService, + pub(crate) restore_checkpoint: Option, +} + +impl Cli { + pub(crate) fn load(self) -> Result { + let config_path = absolute_path(&self.config)?; + let store = Arc::new(TomlConfigStore::new(config_path.clone())); + let persisted = store.load()?.unwrap_or_default(); + let mut dto = persisted.clone(); + let mut command_line_overrides = Vec::new(); + + if let Some(data_dir) = self.data_dir { + dto.data_dir = data_dir; + command_line_overrides.push("data_dir".to_owned()); + } + if self.run_duration_secs.is_some() { + dto.run_duration_secs = self.run_duration_secs; + command_line_overrides.push("run_duration_secs".to_owned()); + } + let base = config_path + .parent() + .ok_or_else(|| AppError::Config("配置文件没有父目录".to_owned()))?; + let app = AppConfig::resolve(dto, base)?; + let config_service = ConfigService::new(store, persisted, command_line_overrides)?; + let restore_checkpoint = self + .restore_checkpoint + .map(|path| absolute_path(&path)) + .transpose()?; + Ok(StartupConfig { + app, + config_service, + restore_checkpoint, + }) + } +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir()?.join(path)) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn resolve(dto: AppConfigDto) -> Result { + AppConfig::resolve(dto, Path::new("C:/config-root")) + } + + #[test] + fn defaults_produce_valid_dht_options() { + let config = resolve(AppConfigDto::default()).unwrap(); + let options = config.dht_options(); + assert_eq!(options.port, 12_313); + assert_eq!(options.metadata.max_worker_count, 400); + assert_eq!(options.metadata.max_connects_per_second, 400); + assert_eq!(options.metadata.max_metadata_size_bytes, 10 * 1024 * 1024); + assert_eq!(options.max_outbound_queries_per_second, 1_000); + assert_eq!(options.sample_infohashes.max_queries_per_second, 60); + assert_eq!(options.sample_infohashes.max_in_flight, 100); + assert_eq!(options.sample_infohashes.new_node_sample_percent, 50); + assert_eq!(options.sample_infohashes.candidate_queue_capacity, 8_192); + assert!(!options.sample_infohashes.fallback_to_iterative); + assert_eq!(options.peer_lookup.max_lookups_per_second, 200); + assert_eq!(options.peer_lookup.max_active_lookups, 200); + assert_eq!(options.crawl.rate_limit.max_find_node_rate_per_sec, 10); + assert_eq!( + options.crawl.rate_limit.max_new_destinations_per_minute, + 12_000 + ); + } + + #[test] + fn dto_round_trips_through_toml() { + let dto = AppConfigDto::default(); + let encoded = toml::to_string(&dto).unwrap(); + let decoded: AppConfigDto = toml::from_str(&encoded).unwrap(); + assert_eq!(decoded.data_dir, dto.data_dir); + assert_eq!(decoded.dht.port, dto.dht.port); + assert_eq!(decoded.http.listen, dto.http.listen); + assert_eq!(decoded.logging.file_prefix, dto.logging.file_prefix); + assert_eq!(decoded.diagnostics.database, dto.diagnostics.database); + } + + #[test] + fn relative_directories_are_resolved_from_config_file() { + let directory = TempDir::new().unwrap(); + let config_path = directory.path().join("service.toml"); + fs::write(&config_path, "data_dir = 'state'").unwrap(); + let startup = Cli { + config: config_path, + data_dir: None, + run_duration_secs: None, + restore_checkpoint: None, + } + .load() + .unwrap(); + let config = startup.app; + assert_eq!(config.data_dir, directory.path().join("state")); + assert_eq!( + config.content_filter_file, + directory.path().join("content-filters.toml") + ); + assert_eq!(config.logging.directory, directory.path().join("data/logs")); + assert_eq!( + config.backup.directory, + directory.path().join("data/backups") + ); + assert_eq!( + config.diagnostics.database, + directory.path().join("data/diagnostics.sqlite3") + ); + assert_eq!(config.http.web_dir, directory.path().join("src/web/dist")); + } + + #[test] + fn unknown_config_field_is_rejected() { + let directory = TempDir::new().unwrap(); + let config_path = directory.path().join("service.toml"); + fs::write(&config_path, "unknown = true").unwrap(); + let error = Cli { + config: config_path, + data_dir: None, + run_duration_secs: None, + restore_checkpoint: None, + } + .load() + .unwrap_err(); + assert!(matches!(error, AppError::Toml(_))); + } + + #[test] + fn zero_metadata_limit_is_rejected() { + let mut dto = AppConfigDto::default(); + dto.metadata_limits.max_files = 0; + assert!(matches!(resolve(dto), Err(AppError::Config(_)))); + } + + #[test] + fn invalid_new_node_sample_percent_is_rejected() { + let mut dto = AppConfigDto::default(); + dto.dht.sample_new_node_percent = 101; + assert!(matches!(resolve(dto), Err(AppError::Config(_)))); + } + + #[test] + fn disk_resume_threshold_must_exceed_minimum() { + let mut dto = AppConfigDto::default(); + dto.disk_guard.resume_free_bytes = dto.disk_guard.minimum_free_bytes; + assert!(matches!(resolve(dto), Err(AppError::Config(_)))); + } + + #[test] + fn logging_requires_at_least_one_output() { + let mut dto = AppConfigDto::default(); + dto.logging.file_enabled = false; + dto.logging.console_enabled = false; + assert!(matches!(resolve(dto), Err(AppError::Config(_)))); + } + + #[test] + fn logging_prefix_cannot_escape_the_log_directory() { + let mut dto = AppConfigDto::default(); + dto.logging.file_prefix = "../service".into(); + assert!(matches!(resolve(dto), Err(AppError::Config(_)))); + } + + #[test] + fn backup_directory_cannot_be_inside_rocksdb() { + let mut dto = AppConfigDto::default(); + dto.backup.directory = dto.data_dir.join("rocksdb/checkpoints"); + assert!(matches!(resolve(dto), Err(AppError::Config(_)))); + } +} diff --git a/src/search/src/config/model.rs b/src/search/src/config/model.rs new file mode 100644 index 0000000..8a946bd --- /dev/null +++ b/src/search/src/config/model.rs @@ -0,0 +1,268 @@ +// 负责定义可序列化的用户配置 DTO 和默认值 + +use std::{net::SocketAddr, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct AppConfigDto { + pub(crate) data_dir: PathBuf, + pub(crate) content_filter_file: PathBuf, + pub(crate) persistence_queue_capacity: usize, + pub(crate) stats_interval_secs: u64, + pub(crate) run_duration_secs: Option, + pub(crate) index_batch_size: usize, + pub(crate) index_interval_millis: u64, + pub(crate) metadata_limits: MetadataLimitsConfig, + pub(crate) dht: DhtConfig, + pub(crate) disk_guard: DiskGuardConfig, + pub(crate) backup: BackupConfig, + pub(crate) diagnostics: DiagnosticsConfig, + pub(crate) logging: LoggingConfig, + pub(crate) http: HttpConfig, + pub(crate) verification: VerificationConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DhtConfig { + pub(crate) port: u16, + pub(crate) netmode: NetworkMode, + pub(crate) hash_queue_capacity: usize, + pub(crate) max_outbound_queries_per_second: u32, + pub(crate) outbound_query_burst: u32, + pub(crate) metadata_timeout_secs: u64, + pub(crate) metadata_queue_capacity: usize, + pub(crate) metadata_workers: usize, + pub(crate) metadata_connects_per_second: u32, + pub(crate) sample_queries_per_second: u32, + pub(crate) sample_max_in_flight: usize, + pub(crate) sample_new_node_percent: u8, + pub(crate) sample_candidate_queue_capacity: usize, + pub(crate) sample_fallback_to_iterative: bool, + pub(crate) peer_lookups_per_second: u32, + pub(crate) peer_lookup_max_active: usize, + pub(crate) find_node_queries_per_second: u32, + pub(crate) find_node_max_in_flight: usize, + pub(crate) new_destinations_per_minute: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct HttpConfig { + pub(crate) listen: SocketAddr, + pub(crate) web_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DiskGuardConfig { + pub(crate) enabled: bool, + pub(crate) check_interval_secs: u64, + pub(crate) minimum_free_bytes: u64, + pub(crate) resume_free_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct LoggingConfig { + pub(crate) directory: PathBuf, + pub(crate) file_enabled: bool, + pub(crate) console_enabled: bool, + pub(crate) rotation: LogRotation, + pub(crate) retain_files: usize, + pub(crate) file_prefix: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct BackupConfig { + pub(crate) enabled: bool, + pub(crate) directory: PathBuf, + pub(crate) interval_secs: u64, + pub(crate) retain_checkpoints: usize, + pub(crate) create_on_start: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct DiagnosticsConfig { + pub(crate) enabled: bool, + pub(crate) database: PathBuf, + pub(crate) sample_interval_secs: u64, + pub(crate) raw_retention_hours: u64, + pub(crate) minute_retention_days: u64, + pub(crate) queue_capacity: usize, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum LogRotation { + Minutely, + Hourly, + #[default] + Daily, + Never, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct VerificationConfig { + pub(crate) enabled: bool, + pub(crate) queue_capacity: usize, + pub(crate) max_active: usize, + pub(crate) max_peer_attempts: usize, + pub(crate) lease_secs: u64, + pub(crate) poll_interval_millis: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct MetadataLimitsConfig { + pub(crate) max_metadata_bytes: usize, + pub(crate) max_files: usize, + pub(crate) max_name_bytes: usize, + pub(crate) max_path_bytes: usize, + pub(crate) max_path_depth: usize, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum NetworkMode { + #[default] + Ipv4Only, + Ipv6Only, + DualStack, +} + +impl Default for AppConfigDto { + fn default() -> Self { + Self { + data_dir: PathBuf::from("data"), + content_filter_file: PathBuf::from("content-filters.toml"), + persistence_queue_capacity: 8_192, + stats_interval_secs: 10, + run_duration_secs: None, + index_batch_size: 1_024, + index_interval_millis: 5_000, + metadata_limits: MetadataLimitsConfig::default(), + dht: DhtConfig::default(), + disk_guard: DiskGuardConfig::default(), + backup: BackupConfig::default(), + diagnostics: DiagnosticsConfig::default(), + logging: LoggingConfig::default(), + http: HttpConfig::default(), + verification: VerificationConfig::default(), + } + } +} + +impl Default for MetadataLimitsConfig { + fn default() -> Self { + Self { + max_metadata_bytes: 10 * 1024 * 1024, + max_files: 20_000, + max_name_bytes: 1_024, + max_path_bytes: 4_096, + max_path_depth: 64, + } + } +} + +impl Default for DhtConfig { + fn default() -> Self { + Self { + port: 12_313, + netmode: NetworkMode::Ipv4Only, + hash_queue_capacity: 20_000, + max_outbound_queries_per_second: 1_000, + outbound_query_burst: 200, + metadata_timeout_secs: 6, + metadata_queue_capacity: 20_000, + metadata_workers: 400, + metadata_connects_per_second: 400, + sample_queries_per_second: 60, + sample_max_in_flight: 100, + sample_new_node_percent: 50, + sample_candidate_queue_capacity: 8_192, + sample_fallback_to_iterative: false, + peer_lookups_per_second: 200, + peer_lookup_max_active: 200, + find_node_queries_per_second: 10, + find_node_max_in_flight: 100, + new_destinations_per_minute: 12_000, + } + } +} + +impl Default for HttpConfig { + fn default() -> Self { + Self { + listen: SocketAddr::from(([127, 0, 0, 1], 8080)), + web_dir: PathBuf::from("src/web/dist"), + } + } +} + +impl Default for DiskGuardConfig { + fn default() -> Self { + Self { + enabled: true, + check_interval_secs: 10, + minimum_free_bytes: 5 * 1024 * 1024 * 1024, + resume_free_bytes: 6 * 1024 * 1024 * 1024, + } + } +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + directory: PathBuf::from("data/logs"), + file_enabled: true, + console_enabled: false, + rotation: LogRotation::Daily, + retain_files: 7, + file_prefix: "dht-search".to_owned(), + } + } +} + +impl Default for BackupConfig { + fn default() -> Self { + Self { + enabled: true, + directory: PathBuf::from("data/backups"), + interval_secs: 6 * 60 * 60, + retain_checkpoints: 3, + create_on_start: true, + } + } +} + +impl Default for DiagnosticsConfig { + fn default() -> Self { + Self { + enabled: true, + database: PathBuf::from("data/diagnostics.sqlite3"), + sample_interval_secs: 10, + raw_retention_hours: 24, + minute_retention_days: 30, + queue_capacity: 128, + } + } +} + +impl Default for VerificationConfig { + fn default() -> Self { + Self { + enabled: true, + queue_capacity: 10_000, + max_active: 8, + max_peer_attempts: 3, + lease_secs: 60, + poll_interval_millis: 250, + } + } +} diff --git a/src/search/src/config/runtime.rs b/src/search/src/config/runtime.rs new file mode 100644 index 0000000..390ce82 --- /dev/null +++ b/src/search/src/config/runtime.rs @@ -0,0 +1,238 @@ +// 负责解析校验用户配置并生成应用可直接使用的运行时配置 + +use std::{fs, ops::Deref, path::Path}; + +use crate::domain::{ContentFilter, ContentFilterConfig, MetadataLimits}; +use dht_crawler::{ + BootstrapOptions, CrawlOptions, DHTOptions, MetadataOptions, NetMode, PeerLookupOptions, + PoolOptions, RateLimitOptions, SampleInfohashesOptions, SchedulerOptions, TargetOptions, +}; + +use crate::error::AppError; + +use super::model::{AppConfigDto, NetworkMode}; + +#[derive(Debug, Clone)] +pub(crate) struct AppConfig(AppConfigDto); + +impl AppConfig { + pub(crate) fn resolve(mut dto: AppConfigDto, base: &Path) -> Result { + dto.data_dir = resolve_path(base, dto.data_dir); + dto.content_filter_file = resolve_path(base, dto.content_filter_file); + dto.logging.directory = resolve_path(base, dto.logging.directory); + dto.backup.directory = resolve_path(base, dto.backup.directory); + dto.diagnostics.database = resolve_path(base, dto.diagnostics.database); + dto.http.web_dir = resolve_path(base, dto.http.web_dir); + let config = Self(dto); + config.validate()?; + Ok(config) + } + + pub(crate) fn content_filter(&self) -> Result { + let contents = fs::read_to_string(&self.content_filter_file)?; + let config = toml::from_str::(&contents)?; + ContentFilter::compile(config).map_err(AppError::from) + } + + pub(crate) fn dht_options(&self) -> DHTOptions { + let defaults = DHTOptions::default(); + DHTOptions { + port: self.dht.port, + netmode: self.dht.netmode.into(), + hash_queue_capacity: self.dht.hash_queue_capacity, + max_outbound_queries_per_second: self.dht.max_outbound_queries_per_second, + outbound_query_burst: self.dht.outbound_query_burst, + metadata: MetadataOptions { + timeout_secs: self.dht.metadata_timeout_secs, + max_queue_size: self.dht.metadata_queue_capacity, + max_worker_count: self.dht.metadata_workers, + max_connects_per_second: self.dht.metadata_connects_per_second, + max_metadata_size_bytes: self.metadata_limits.max_metadata_bytes, + ..defaults.metadata + }, + peer_lookup: PeerLookupOptions { + max_lookups_per_second: self.dht.peer_lookups_per_second, + burst: self.dht.peer_lookups_per_second.max(1), + max_active_lookups: self.dht.peer_lookup_max_active, + }, + sample_infohashes: SampleInfohashesOptions { + max_queries_per_second: self.dht.sample_queries_per_second, + burst: self.dht.sample_queries_per_second.max(1), + max_in_flight: self.dht.sample_max_in_flight, + new_node_sample_percent: self.dht.sample_new_node_percent, + candidate_queue_capacity: self.dht.sample_candidate_queue_capacity, + fallback_to_iterative: self.dht.sample_fallback_to_iterative, + ..defaults.sample_infohashes + }, + crawl: CrawlOptions { + pool: PoolOptions { + ..defaults.crawl.pool + }, + rate_limit: RateLimitOptions { + max_find_node_rate_per_sec: self.dht.find_node_queries_per_second, + burst: self.dht.outbound_query_burst, + max_in_flight: self.dht.find_node_max_in_flight, + max_new_destinations_per_minute: self.dht.new_destinations_per_minute, + ..defaults.crawl.rate_limit + }, + bootstrap: BootstrapOptions { + ..defaults.crawl.bootstrap + }, + target: TargetOptions { + ..defaults.crawl.target + }, + scheduler: SchedulerOptions { + ..defaults.crawl.scheduler + }, + }, + } + } + + pub(crate) fn metadata_limits(&self) -> MetadataLimits { + MetadataLimits { + max_files: self.metadata_limits.max_files, + max_name_bytes: self.metadata_limits.max_name_bytes, + max_path_bytes: self.metadata_limits.max_path_bytes, + max_path_depth: self.metadata_limits.max_path_depth, + } + } + + fn validate(&self) -> Result<(), AppError> { + if self.persistence_queue_capacity == 0 { + return Err(AppError::Config( + "persistence_queue_capacity 必须大于零".to_owned(), + )); + } + if self.stats_interval_secs == 0 { + return Err(AppError::Config( + "stats_interval_secs 必须大于零".to_owned(), + )); + } + if self.run_duration_secs == Some(0) { + return Err(AppError::Config( + "run_duration_secs 必须大于零或不设置".to_owned(), + )); + } + if self.index_batch_size == 0 || self.index_interval_millis == 0 { + return Err(AppError::Config( + "索引批量大小和执行间隔必须大于零".to_owned(), + )); + } + if self.metadata_limits.max_metadata_bytes == 0 + || self.metadata_limits.max_files == 0 + || self.metadata_limits.max_name_bytes == 0 + || self.metadata_limits.max_path_bytes == 0 + || self.metadata_limits.max_path_depth == 0 + { + return Err(AppError::Config( + "Metadata 大小文件数名称路径和目录层级上限必须大于零".to_owned(), + )); + } + if self.dht.metadata_workers == 0 || self.dht.metadata_queue_capacity == 0 { + return Err(AppError::Config( + "Metadata worker 和队列容量必须大于零".to_owned(), + )); + } + if self.verification.queue_capacity == 0 + || self.verification.max_active == 0 + || self.verification.max_peer_attempts == 0 + || self.verification.lease_secs == 0 + || self.verification.poll_interval_millis == 0 + { + return Err(AppError::Config( + "验证队列容量并发尝试数租约和轮询间隔必须大于零".to_owned(), + )); + } + if self.disk_guard.check_interval_secs == 0 + || self.disk_guard.minimum_free_bytes == 0 + || self.disk_guard.resume_free_bytes <= self.disk_guard.minimum_free_bytes + { + return Err(AppError::Config( + "磁盘检查间隔必须大于零且恢复阈值必须大于保护阈值".to_owned(), + )); + } + if self.backup.interval_secs == 0 || self.backup.retain_checkpoints == 0 { + return Err(AppError::Config( + "备份间隔和检查点保留数量必须大于零".to_owned(), + )); + } + if self.diagnostics.sample_interval_secs == 0 + || self.diagnostics.raw_retention_hours == 0 + || self.diagnostics.minute_retention_days == 0 + || self.diagnostics.queue_capacity == 0 + { + return Err(AppError::Config( + "诊断采样间隔保留时间和队列容量必须大于零".to_owned(), + )); + } + let database_path = self.data_dir.join("rocksdb"); + if self.backup.directory == database_path + || self.backup.directory.starts_with(&database_path) + { + return Err(AppError::Config( + "检查点目录不能位于 RocksDB 数据库目录内部".to_owned(), + )); + } + if !self.logging.file_enabled && !self.logging.console_enabled { + return Err(AppError::Config( + "文件日志和终端日志不能同时关闭".to_owned(), + )); + } + if self.logging.file_enabled && self.logging.retain_files == 0 { + return Err(AppError::Config("日志保留文件数量必须大于零".to_owned())); + } + if self.logging.file_prefix.trim().is_empty() + || self + .logging + .file_prefix + .chars() + .any(|character| character.is_control() || matches!(character, '/' | '\\')) + { + return Err(AppError::Config( + "日志文件前缀不能为空且不能包含路径分隔符或控制字符".to_owned(), + )); + } + if self.dht.max_outbound_queries_per_second == 0 + || self.dht.outbound_query_burst == 0 + || self.dht.metadata_connects_per_second == 0 + || self.dht.sample_max_in_flight == 0 + || self.dht.sample_candidate_queue_capacity == 0 + || self.dht.peer_lookup_max_active == 0 + || self.dht.find_node_max_in_flight == 0 + { + return Err(AppError::Config("网络速率和并发上限必须大于零".to_owned())); + } + if self.dht.sample_new_node_percent > 100 { + return Err(AppError::Config( + "sample_new_node_percent 必须在 0 到 100 之间".to_owned(), + )); + } + Ok(()) + } +} + +impl Deref for AppConfig { + type Target = AppConfigDto; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for NetMode { + fn from(value: NetworkMode) -> Self { + match value { + NetworkMode::Ipv4Only => Self::Ipv4Only, + NetworkMode::Ipv6Only => Self::Ipv6Only, + NetworkMode::DualStack => Self::DualStack, + } + } +} + +fn resolve_path(base: &Path, path: std::path::PathBuf) -> std::path::PathBuf { + if path.is_absolute() { + path + } else { + base.join(path) + } +} diff --git a/src/search/src/config/service.rs b/src/search/src/config/service.rs new file mode 100644 index 0000000..bab1c03 --- /dev/null +++ b/src/search/src/config/service.rs @@ -0,0 +1,221 @@ +// 负责配置修订查询完整校验并发覆盖保护和持久化更新 + +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; + +use super::{AppConfig, AppConfigDto, ConfigStore}; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct ConfigSnapshot { + pub(crate) revision: String, + pub(crate) restart_required: bool, + pub(crate) source: String, + pub(crate) command_line_overrides: Vec, + pub(crate) config: AppConfigDto, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct ConfigUpdateRequest { + pub(crate) revision: String, + pub(crate) config: AppConfigDto, +} + +#[derive(Clone)] +pub(crate) struct ConfigService { + inner: Arc, +} + +impl std::fmt::Debug for ConfigService { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConfigService") + .field("snapshot", &self.snapshot()) + .finish() + } +} + +struct ConfigServiceInner { + store: Arc, + base: PathBuf, + startup: AppConfigDto, + command_line_overrides: Vec, + state: Mutex, +} + +struct ConfigState { + revision: String, + config: AppConfigDto, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ConfigServiceError { + #[error("配置已经被其他请求修改 请重新加载后再保存")] + Conflict, + #[error("配置校验失败: {0}")] + Validation(String), + #[error("配置保存失败: {0}")] + Persistence(String), +} + +impl ConfigService { + pub(crate) fn new( + store: Arc, + initial: AppConfigDto, + command_line_overrides: Vec, + ) -> Result { + let base = store + .path() + .parent() + .ok_or_else(|| AppError::Config("配置文件没有父目录".to_owned()))? + .to_path_buf(); + let revision = revision(&initial)?; + Ok(Self { + inner: Arc::new(ConfigServiceInner { + store, + base, + startup: initial.clone(), + command_line_overrides, + state: Mutex::new(ConfigState { + revision, + config: initial, + }), + }), + }) + } + + pub(crate) fn snapshot(&self) -> ConfigSnapshot { + let state = self + .inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + self.snapshot_from(&state) + } + + pub(crate) fn update( + &self, + request: ConfigUpdateRequest, + ) -> Result { + AppConfig::resolve(request.config.clone(), &self.inner.base) + .map_err(|error| ConfigServiceError::Validation(error.to_string()))?; + let candidate_revision = revision(&request.config) + .map_err(|error| ConfigServiceError::Persistence(error.to_string()))?; + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if request.revision != state.revision { + return Err(ConfigServiceError::Conflict); + } + self.inner + .store + .save(&request.config) + .map_err(|error| ConfigServiceError::Persistence(error.to_string()))?; + state.config = request.config; + state.revision = candidate_revision; + Ok(self.snapshot_from(&state)) + } + + fn snapshot_from(&self, state: &ConfigState) -> ConfigSnapshot { + ConfigSnapshot { + revision: state.revision.clone(), + restart_required: state.config != self.inner.startup, + source: self.inner.store.path().to_string_lossy().into_owned(), + command_line_overrides: self.inner.command_line_overrides.clone(), + config: state.config.clone(), + } + } +} + +fn revision(config: &AppConfigDto) -> Result { + let canonical = toml::to_string(config)?; + Ok(blake3::hash(canonical.as_bytes()).to_hex().to_string()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::config::TomlConfigStore; + + fn service(directory: &TempDir) -> ConfigService { + ConfigService::new( + Arc::new(TomlConfigStore::new(directory.path().join("service.toml"))), + AppConfigDto::default(), + Vec::new(), + ) + .unwrap() + } + + #[test] + fn valid_update_is_persisted_and_requires_restart() { + let directory = TempDir::new().unwrap(); + let service = service(&directory); + let current = service.snapshot(); + let mut config = current.config; + config.dht.port = 22_313; + let updated = service + .update(ConfigUpdateRequest { + revision: current.revision, + config: config.clone(), + }) + .unwrap(); + assert!(updated.restart_required); + assert_eq!(updated.config, config); + let stored = TomlConfigStore::new(directory.path().join("service.toml")) + .load() + .unwrap(); + assert_eq!(stored, Some(config)); + } + + #[test] + fn stale_revision_cannot_overwrite_a_newer_update() { + let directory = TempDir::new().unwrap(); + let service = service(&directory); + let original = service.snapshot(); + let mut first = original.config.clone(); + first.dht.port = 22_313; + service + .update(ConfigUpdateRequest { + revision: original.revision.clone(), + config: first.clone(), + }) + .unwrap(); + let mut stale = original.config; + stale.dht.port = 32_313; + assert!(matches!( + service.update(ConfigUpdateRequest { + revision: original.revision, + config: stale, + }), + Err(ConfigServiceError::Conflict) + )); + assert_eq!(service.snapshot().config, first); + } + + #[test] + fn invalid_update_does_not_change_memory_or_disk() { + let directory = TempDir::new().unwrap(); + let service = service(&directory); + let current = service.snapshot(); + let mut invalid = current.config.clone(); + invalid.persistence_queue_capacity = 0; + assert!(matches!( + service.update(ConfigUpdateRequest { + revision: current.revision, + config: invalid, + }), + Err(ConfigServiceError::Validation(_)) + )); + assert_eq!(service.snapshot().config, current.config); + assert!(!directory.path().join("service.toml").exists()); + } +} diff --git a/src/search/src/config/store.rs b/src/search/src/config/store.rs new file mode 100644 index 0000000..3493f74 --- /dev/null +++ b/src/search/src/config/store.rs @@ -0,0 +1,155 @@ +// 负责从持久化介质读取并原子保存用户配置 DTO + +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use crate::error::AppError; + +use super::AppConfigDto; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub(crate) trait ConfigStore: Send + Sync { + fn load(&self) -> Result, AppError>; + fn save(&self, config: &AppConfigDto) -> Result<(), AppError>; + fn path(&self) -> &Path; +} + +#[derive(Debug, Clone)] +pub(crate) struct TomlConfigStore { + path: PathBuf, +} + +impl TomlConfigStore { + pub(crate) fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl ConfigStore for TomlConfigStore { + fn load(&self) -> Result, AppError> { + if !self.path.exists() { + return Ok(None); + } + let contents = fs::read_to_string(&self.path)?; + toml::from_str(&contents).map(Some).map_err(AppError::from) + } + + fn save(&self, config: &AppConfigDto) -> Result<(), AppError> { + let contents = toml::to_string_pretty(config)?; + let parent = self + .path + .parent() + .ok_or_else(|| AppError::Config("配置文件没有父目录".to_owned()))?; + fs::create_dir_all(parent)?; + let temporary = temporary_path(&self.path); + let result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&temporary)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + drop(file); + replace_file(&temporary, &self.path)?; + sync_parent(parent)?; + Ok::<(), std::io::Error>(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.map_err(AppError::from) + } + + fn path(&self) -> &Path { + &self.path + } +} + +fn temporary_path(destination: &Path) -> PathBuf { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let file_name = destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("dht-search.toml"); + destination.with_file_name(format!( + ".{file_name}.tmp-{}-{sequence}", + std::process::id() + )) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let destination: Vec = destination + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + if unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } == 0 + { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> std::io::Result<()> { + fs::rename(source, destination) +} + +#[cfg(unix)] +fn sync_parent(parent: &Path) -> std::io::Result<()> { + std::fs::File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent(_parent: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn save_replaces_complete_toml_and_leaves_no_temporary_file() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("service.toml"); + fs::write(&path, "data_dir = 'old'").unwrap(); + let store = TomlConfigStore::new(path.clone()); + let expected = AppConfigDto { + data_dir: PathBuf::from("new-data"), + ..AppConfigDto::default() + }; + store.save(&expected).unwrap(); + assert_eq!(store.load().unwrap(), Some(expected)); + assert_eq!( + fs::read_dir(directory.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp-")) + .count(), + 0 + ); + } +} diff --git a/src/search/src/crawler/mapper.rs b/src/search/src/crawler/mapper.rs new file mode 100644 index 0000000..40e6c54 --- /dev/null +++ b/src/search/src/crawler/mapper.rs @@ -0,0 +1,23 @@ +// 负责将 DHT 采集结果转换为不依赖传输层的领域输入 + +use crate::domain::{MetadataCandidate, TorrentFile}; +use dht_crawler::TorrentInfo; + +pub(crate) fn metadata_candidate(info: TorrentInfo) -> MetadataCandidate { + MetadataCandidate { + info_hash: info.info_hash, + name: info.name, + total_size: info.total_size, + files: info + .files + .into_iter() + .map(|file| TorrentFile { + path: file.path, + size: file.size, + }) + .collect(), + piece_length: info.piece_length, + source_peers: info.peers, + timestamp: info.timestamp, + } +} diff --git a/dht-search/src/crawler/mod.rs b/src/search/src/crawler/mod.rs similarity index 80% rename from dht-search/src/crawler/mod.rs rename to src/search/src/crawler/mod.rs index d5dd812..cf3b9ec 100644 --- a/dht-search/src/crawler/mod.rs +++ b/src/search/src/crawler/mod.rs @@ -1,3 +1,4 @@ // 负责组合 DHT 发现 Metadata 下载和持久化提交管线 +pub(crate) mod mapper; pub(crate) mod pipeline; diff --git a/dht-search/src/crawler/pipeline.rs b/src/search/src/crawler/pipeline.rs similarity index 92% rename from dht-search/src/crawler/pipeline.rs rename to src/search/src/crawler/pipeline.rs index 8d52572..122f8b5 100644 --- a/dht-search/src/crawler/pipeline.rs +++ b/src/search/src/crawler/pipeline.rs @@ -10,13 +10,14 @@ use std::{ thread::{self, JoinHandle}, }; -use dht_crawler::TorrentInfo; -use dht_search::{ +use crate::{ domain::{InfoHash, MetadataLimits, MetadataRejectionReason, RejectedMetadata, TorrentRecord}, storage::{StorageError, TorrentRepository, UpsertOutcome}, }; +use dht_crawler::TorrentInfo; use tokio::sync::oneshot; +use crate::crawler::mapper::metadata_candidate; use crate::disk_guard::DiskGuard; use crate::error::AppError; @@ -145,25 +146,26 @@ impl PersistenceIngress { }; let rejected_info_hash = InfoHash::from_str(&torrent.info_hash).ok(); let rejected_at = torrent.timestamp; - let record = match TorrentRecord::try_from_with_limits(torrent, self.limits) { - Ok(record) => record, - Err(error) => { - self.stats.invalid.fetch_add(1, Ordering::Relaxed); - let reason = error.rejection_reason(); - self.stats.filtered[reason.index()].fetch_add(1, Ordering::Relaxed); - tracing::warn!(%error, ?reason, "拒绝无效 Metadata"); - if let Some(info_hash) = rejected_info_hash { - let rejection = RejectedMetadata::new( - info_hash, - reason, - self.limits.rule_id(), - rejected_at, - ); - self.try_send(PersistenceItem::Rejection(rejection), false); + let record = + match TorrentRecord::try_from_with_limits(metadata_candidate(torrent), self.limits) { + Ok(record) => record, + Err(error) => { + self.stats.invalid.fetch_add(1, Ordering::Relaxed); + let reason = error.rejection_reason(); + self.stats.filtered[reason.index()].fetch_add(1, Ordering::Relaxed); + tracing::warn!(%error, ?reason, "拒绝无效 Metadata"); + if let Some(info_hash) = rejected_info_hash { + let rejection = RejectedMetadata::new( + info_hash, + reason, + self.limits.rule_id(), + rejected_at, + ); + self.try_send(PersistenceItem::Rejection(rejection), false); + } + return false; } - return false; - } - }; + }; self.try_send(PersistenceItem::Record(record), true) } @@ -234,14 +236,14 @@ impl MetadataFilterSnapshot { mod tests { use std::sync::Mutex; - use dht_crawler::FileInfo; - use dht_search::{ + use crate::{ domain::{ContentGroup, InfoHash, VerificationResult}, storage::{ ContentGroupTask, ContentVariants, UpsertOutcome, VerificationEnqueueOutcome, VerificationPriority, VerificationRequest, }, }; + use dht_crawler::FileInfo; use super::*; use crate::config::DiskGuardConfig; diff --git a/src/search/src/diagnostics/http.rs b/src/search/src/diagnostics/http.rs new file mode 100644 index 0000000..9082ae7 --- /dev/null +++ b/src/search/src/diagnostics/http.rs @@ -0,0 +1,166 @@ +// 负责记录 HTTP 请求并发状态错误分类和固定桶延迟分布 + +use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; + +use super::model::HttpDiagnostics; + +const LATENCY_BUCKETS_MICROS: [u64; 11] = [ + 1_000, + 5_000, + 10_000, + 25_000, + 50_000, + 100_000, + 250_000, + 500_000, + 1_000_000, + 5_000_000, + u64::MAX, +]; + +#[derive(Clone, Default)] +pub(crate) struct HttpStats { + inner: Arc, +} + +struct HttpStatsInner { + active: AtomicU64, + requests: AtomicU64, + client_errors: AtomicU64, + server_errors: AtomicU64, + latency_total_micros: AtomicU64, + latency_max_micros: AtomicU64, + latency_buckets: [AtomicU64; LATENCY_BUCKETS_MICROS.len()], +} + +impl Default for HttpStatsInner { + fn default() -> Self { + Self { + active: AtomicU64::new(0), + requests: AtomicU64::new(0), + client_errors: AtomicU64::new(0), + server_errors: AtomicU64::new(0), + latency_total_micros: AtomicU64::new(0), + latency_max_micros: AtomicU64::new(0), + latency_buckets: std::array::from_fn(|_| AtomicU64::new(0)), + } + } +} + +pub(crate) struct HttpRequestTimer { + stats: HttpStats, + started: Instant, +} + +impl HttpStats { + pub(crate) fn begin(&self) -> HttpRequestTimer { + self.inner.active.fetch_add(1, Ordering::Relaxed); + HttpRequestTimer { + stats: self.clone(), + started: Instant::now(), + } + } + + pub(crate) fn snapshot(&self) -> HttpDiagnostics { + let requests = self.inner.requests.load(Ordering::Relaxed); + let total_micros = self.inner.latency_total_micros.load(Ordering::Relaxed); + HttpDiagnostics { + active_requests: self.inner.active.load(Ordering::Relaxed), + requests, + client_errors: self.inner.client_errors.load(Ordering::Relaxed), + server_errors: self.inner.server_errors.load(Ordering::Relaxed), + latency_average_micros: (requests != 0).then(|| total_micros / requests), + latency_p95_millis: percentile_millis(&self.inner.latency_buckets, requests, 95), + latency_max_millis: micros_to_millis( + self.inner.latency_max_micros.load(Ordering::Relaxed), + ), + } + } + + fn observe(&self, status: u16, elapsed_micros: u64) { + self.inner.requests.fetch_add(1, Ordering::Relaxed); + if (400..500).contains(&status) { + self.inner.client_errors.fetch_add(1, Ordering::Relaxed); + } else if status >= 500 { + self.inner.server_errors.fetch_add(1, Ordering::Relaxed); + } + let _ = self.inner.latency_total_micros.fetch_update( + Ordering::Relaxed, + Ordering::Relaxed, + |value| Some(value.saturating_add(elapsed_micros)), + ); + self.inner + .latency_max_micros + .fetch_max(elapsed_micros, Ordering::Relaxed); + let index = LATENCY_BUCKETS_MICROS + .iter() + .position(|upper| elapsed_micros <= *upper) + .unwrap_or(LATENCY_BUCKETS_MICROS.len() - 1); + self.inner.latency_buckets[index].fetch_add(1, Ordering::Relaxed); + } +} + +impl HttpRequestTimer { + pub(crate) fn finish(self, status: u16) { + let elapsed_micros = self.started.elapsed().as_micros().min(u128::from(u64::MAX)) as u64; + self.stats.observe(status, elapsed_micros); + } +} + +impl Drop for HttpRequestTimer { + fn drop(&mut self) { + self.stats.inner.active.fetch_sub(1, Ordering::Relaxed); + } +} + +fn percentile_millis( + buckets: &[AtomicU64; LATENCY_BUCKETS_MICROS.len()], + count: u64, + percentile: u64, +) -> Option { + if count == 0 { + return None; + } + let rank = count.saturating_mul(percentile).saturating_add(99) / 100; + let mut cumulative = 0_u64; + for (index, bucket) in buckets.iter().enumerate() { + cumulative = cumulative.saturating_add(bucket.load(Ordering::Relaxed)); + if cumulative >= rank { + let upper = LATENCY_BUCKETS_MICROS[index]; + return (upper != u64::MAX).then(|| micros_to_millis(upper)); + } + } + None +} + +fn micros_to_millis(micros: u64) -> u64 { + micros.saturating_add(999) / 1_000 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshots_track_active_completed_and_error_requests() { + let stats = HttpStats::default(); + let active = stats.begin(); + assert_eq!(stats.snapshot().active_requests, 1); + active.finish(404); + let failed = stats.begin(); + failed.finish(503); + let snapshot = stats.snapshot(); + assert_eq!(snapshot.active_requests, 0); + assert_eq!(snapshot.requests, 2); + assert_eq!(snapshot.client_errors, 1); + assert_eq!(snapshot.server_errors, 1); + assert!(snapshot.latency_average_micros.is_some()); + assert!(snapshot.latency_p95_millis.is_some()); + } +} diff --git a/src/search/src/diagnostics/mod.rs b/src/search/src/diagnostics/mod.rs new file mode 100644 index 0000000..d17f10c --- /dev/null +++ b/src/search/src/diagnostics/mod.rs @@ -0,0 +1,392 @@ +// 负责采集运行资源快照并协调有界 SQLite 历史写入和查询 + +mod http; +mod model; +mod process; +mod store; + +use std::{ + path::PathBuf, + sync::{ + Arc, Mutex, RwLock, + atomic::{AtomicU64, Ordering}, + mpsc::{SyncSender, TrySendError, sync_channel}, + }, + thread::JoinHandle, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + search::SearchEngine, + storage::{RocksTorrentRepository, StorageDiagnostics as RocksDiagnostics}, +}; +use dht_crawler::DhtRuntimeStats; +use tokio_util::sync::CancellationToken; + +use crate::{ + config::DiagnosticsConfig, crawler::pipeline::PersistenceIngress, disk_guard::DiskGuard, +}; + +pub(crate) use http::HttpStats; +pub(crate) use model::{ + CurrentDiagnosticsResponse, DiagnosticHistory, DiagnosticSample, HistoryResolution, +}; +use model::{DiagnosticsStatus, RuntimeDiagnostics, SearchDiagnostics, StorageDiagnostics}; +use store::{DiagnosticStore, DiagnosticStoreError}; + +#[derive(Clone)] +pub(crate) struct DiagnosticSources { + pub(crate) repository: Arc, + pub(crate) search: SearchEngine, + pub(crate) dht: DhtRuntimeStats, + pub(crate) persistence: PersistenceIngress, + pub(crate) disk_guard: DiskGuard, + pub(crate) http: HttpStats, +} + +#[derive(Clone)] +pub(crate) struct DiagnosticsHandle { + state: Arc, + database: Option, +} + +pub(crate) struct DiagnosticsRuntime { + handle: DiagnosticsHandle, + cancel: CancellationToken, + collector: Option>, + writer: Option>, +} + +#[derive(Default)] +struct DiagnosticsState { + enabled: bool, + current: RwLock>, + persisted_samples: AtomicU64, + dropped_samples: AtomicU64, + skipped_samples: AtomicU64, + write_failures: AtomicU64, + last_persisted_at: AtomicU64, + last_error: Mutex>, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum DiagnosticsError { + #[error("运行诊断线程启动失败: {0}")] + Io(#[from] std::io::Error), + #[error(transparent)] + Store(#[from] DiagnosticStoreError), + #[error("运行诊断历史未启用")] + Disabled, + #[error("运行诊断 writer 异常退出")] + WriterPanicked, +} + +impl DiagnosticsRuntime { + pub(crate) fn disabled() -> Self { + Self { + handle: DiagnosticsHandle { + state: Arc::new(DiagnosticsState::default()), + database: None, + }, + cancel: CancellationToken::new(), + collector: None, + writer: None, + } + } + + pub(crate) fn start( + config: DiagnosticsConfig, + sources: DiagnosticSources, + ) -> Result { + let store = DiagnosticStore::open(&config.database)?; + let state = Arc::new(DiagnosticsState { + enabled: true, + ..DiagnosticsState::default() + }); + let (sender, receiver) = sync_channel(config.queue_capacity); + let writer_state = state.clone(); + let writer_config = config.clone(); + let writer_disk_guard = sources.disk_guard.clone(); + let writer = std::thread::Builder::new() + .name("diagnostics-sqlite".to_owned()) + .spawn(move || { + let mut store = store; + while let Ok(sample) = receiver.recv() { + let Some(_permit) = writer_disk_guard.begin_new_write() else { + writer_state.skipped_samples.fetch_add(1, Ordering::Relaxed); + continue; + }; + match store.record(&sample, &writer_config) { + Ok(()) => { + writer_state + .persisted_samples + .fetch_add(1, Ordering::Relaxed); + writer_state + .last_persisted_at + .store(sample.captured_at, Ordering::Relaxed); + } + Err(error) => { + writer_state.write_failures.fetch_add(1, Ordering::Relaxed); + set_last_error(&writer_state, error.to_string()); + tracing::warn!(%error, "运行诊断采样写入失败"); + } + } + } + })?; + let cancel = CancellationToken::new(); + let collector = tokio::spawn(collect_loop( + sources, + sender, + state.clone(), + config.sample_interval_secs, + cancel.clone(), + )); + Ok(Self { + handle: DiagnosticsHandle { + state, + database: Some(config.database), + }, + cancel, + collector: Some(collector), + writer: Some(writer), + }) + } + + pub(crate) fn handle(&self) -> DiagnosticsHandle { + self.handle.clone() + } + + pub(crate) fn request_shutdown(&self) { + self.cancel.cancel(); + } + + pub(crate) async fn shutdown(mut self) -> Result<(), DiagnosticsError> { + self.cancel.cancel(); + if let Some(collector) = self.collector.take() { + let _ = collector.await; + } + if let Some(writer) = self.writer.take() { + tokio::task::spawn_blocking(move || writer.join()) + .await + .map_err(|_| DiagnosticsError::WriterPanicked)? + .map_err(|_| DiagnosticsError::WriterPanicked)?; + } + Ok(()) + } +} + +impl DiagnosticsHandle { + pub(crate) fn current(&self) -> CurrentDiagnosticsResponse { + let last_persisted_at = self.state.last_persisted_at.load(Ordering::Relaxed); + CurrentDiagnosticsResponse { + status: DiagnosticsStatus { + enabled: self.state.enabled, + persisted_samples: self.state.persisted_samples.load(Ordering::Relaxed), + dropped_samples: self.state.dropped_samples.load(Ordering::Relaxed), + skipped_samples: self.state.skipped_samples.load(Ordering::Relaxed), + write_failures: self.state.write_failures.load(Ordering::Relaxed), + last_persisted_at: (last_persisted_at != 0).then_some(last_persisted_at), + last_error: self + .state + .last_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + }, + sample: self + .state + .current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + } + } + + pub(crate) fn history( + &self, + resolution: HistoryResolution, + from: u64, + to: u64, + ) -> Result { + let database = self.database.as_ref().ok_or(DiagnosticsError::Disabled)?; + DiagnosticStore::history(database, resolution, from, to).map_err(Into::into) + } +} + +async fn collect_loop( + sources: DiagnosticSources, + sender: SyncSender, + state: Arc, + interval_secs: u64, + cancel: CancellationToken, +) { + let session_started_at = unix_timestamp(); + let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = ticker.tick() => { + let sources = sources.clone(); + let sample = match tokio::task::spawn_blocking(move || { + collect_sample(&sources, session_started_at) + }).await { + Ok(sample) => sample, + Err(error) => { + state.dropped_samples.fetch_add(1, Ordering::Relaxed); + set_last_error(&state, error.to_string()); + continue; + } + }; + *state + .current + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sample.clone()); + match sender.try_send(sample) { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + state.dropped_samples.fetch_add(1, Ordering::Relaxed); + } + Err(TrySendError::Disconnected(_)) => { + state.dropped_samples.fetch_add(1, Ordering::Relaxed); + set_last_error(&state, "运行诊断 writer 已停止".to_owned()); + break; + } + } + } + } + } +} + +fn collect_sample(sources: &DiagnosticSources, session_started_at: u64) -> DiagnosticSample { + let dht = sources.dht.snapshot(); + let observability = sources.dht.observability_snapshot(); + let persistence = sources.persistence.snapshot(); + let disk = sources.disk_guard.snapshot(); + let storage = sources.repository.diagnostics().unwrap_or_else(|error| { + tracing::warn!(%error, "读取 RocksDB 诊断属性失败"); + RocksDiagnostics::default() + }); + let search = sources.search.diagnostics(); + DiagnosticSample { + captured_at: unix_timestamp(), + session_started_at, + process: process::snapshot(), + storage: StorageDiagnostics { + block_cache_bytes: storage.block_cache_bytes, + memtable_bytes: storage.memtable_bytes, + pending_compaction_bytes: storage.pending_compaction_bytes, + live_sst_bytes: storage.live_sst_bytes, + running_compactions: storage.running_compactions, + estimated_keys: storage.estimated_keys, + }, + search: SearchDiagnostics { + documents: search.documents, + writer_memory_budget_bytes: search.writer_memory_budget_bytes, + commits: search.commits, + commit_failures: search.commit_failures, + last_commit_at: search.last_commit_at, + last_commit_duration_millis: search.last_commit_duration_millis, + last_commit_documents: search.last_commit_documents, + }, + http: sources.http.snapshot(), + runtime: RuntimeDiagnostics { + nodes: saturating_u64(dht.node_pool_size), + udp_tx_packets: observability.udp_tx_packets, + metadata_in_flight: saturating_u64(dht.metadata_in_flight), + metadata_succeeded: dht.metadata_peer_succeeded, + metadata_failed: dht.metadata_peer_failed, + sample_queue_depth: saturating_u64(dht.sample_candidate_queue_depth), + sample_queue_capacity: saturating_u64(dht.sample_candidate_queue_capacity), + persistence_queue_depth: saturating_u64(persistence.queue_depth), + indexed_documents: search.documents, + disk_available_bytes: disk.available_bytes, + }, + } +} + +fn set_last_error(state: &DiagnosticsState, message: String) { + *state + .last_error + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(message); +} + +fn saturating_u64(value: usize) -> u64 { + value.min(u64::MAX as usize) as u64 +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use crate::{ + search::SearchEngine, + storage::{RocksTorrentRepository, TorrentRepository}, + }; + use tempfile::TempDir; + + use super::*; + use crate::{ + config::DiskGuardConfig, crawler::pipeline::PersistencePipeline, disk_guard::DiskGuard, + }; + + #[tokio::test] + async fn runtime_persists_a_queryable_snapshot_and_stops_cleanly() { + let directory = TempDir::new().unwrap(); + let repository = + Arc::new(RocksTorrentRepository::open(directory.path().join("rocksdb")).unwrap()); + let repository_trait: Arc = repository.clone(); + let disk_guard = DiskGuard::new(&DiskGuardConfig { + enabled: false, + ..DiskGuardConfig::default() + }); + let persistence = PersistencePipeline::start( + repository_trait, + 4, + crate::domain::MetadataLimits::default(), + disk_guard.clone(), + ); + let config = DiagnosticsConfig { + database: directory.path().join("diagnostics.sqlite3"), + sample_interval_secs: 1, + ..DiagnosticsConfig::default() + }; + let runtime = DiagnosticsRuntime::start( + config, + DiagnosticSources { + repository, + search: SearchEngine::open(directory.path().join("tantivy")).unwrap(), + dht: DhtRuntimeStats::default(), + persistence: persistence.ingress.clone(), + disk_guard, + http: HttpStats::default(), + }, + ) + .unwrap(); + let handle = runtime.handle(); + for _ in 0..40 { + if handle.current().status.persisted_samples > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + let current = handle.current(); + assert!(current.status.enabled); + assert_eq!(current.status.write_failures, 0); + assert!(current.sample.is_some()); + let now = unix_timestamp(); + let history = handle + .history(HistoryResolution::Raw, now.saturating_sub(5), now) + .unwrap(); + assert!(!history.samples.is_empty()); + + runtime.shutdown().await.unwrap(); + persistence.close_and_join().await.unwrap(); + } +} diff --git a/src/search/src/diagnostics/model.rs b/src/search/src/diagnostics/model.rs new file mode 100644 index 0000000..0ef3816 --- /dev/null +++ b/src/search/src/diagnostics/model.rs @@ -0,0 +1,117 @@ +// 负责定义运行诊断采样状态和历史查询 DTO + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct DiagnosticSample { + pub(crate) captured_at: u64, + pub(crate) session_started_at: u64, + pub(crate) process: ProcessDiagnostics, + pub(crate) storage: StorageDiagnostics, + pub(crate) search: SearchDiagnostics, + #[serde(default)] + pub(crate) http: HttpDiagnostics, + pub(crate) runtime: RuntimeDiagnostics, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct ProcessDiagnostics { + pub(crate) resident_memory_bytes: Option, + pub(crate) private_memory_bytes: Option, + pub(crate) cpu_time_millis: Option, + pub(crate) thread_count: Option, + pub(crate) handle_count: Option, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct StorageDiagnostics { + pub(crate) block_cache_bytes: Option, + pub(crate) memtable_bytes: Option, + pub(crate) pending_compaction_bytes: Option, + pub(crate) live_sst_bytes: Option, + pub(crate) running_compactions: Option, + pub(crate) estimated_keys: Option, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct SearchDiagnostics { + pub(crate) documents: u64, + pub(crate) writer_memory_budget_bytes: u64, + pub(crate) commits: u64, + pub(crate) commit_failures: u64, + pub(crate) last_commit_at: Option, + pub(crate) last_commit_duration_millis: u64, + pub(crate) last_commit_documents: u64, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct HttpDiagnostics { + pub(crate) active_requests: u64, + pub(crate) requests: u64, + pub(crate) client_errors: u64, + pub(crate) server_errors: u64, + pub(crate) latency_average_micros: Option, + pub(crate) latency_p95_millis: Option, + pub(crate) latency_max_millis: u64, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub(crate) struct RuntimeDiagnostics { + pub(crate) nodes: u64, + pub(crate) udp_tx_packets: u64, + pub(crate) metadata_in_flight: u64, + pub(crate) metadata_succeeded: u64, + pub(crate) metadata_failed: u64, + pub(crate) sample_queue_depth: u64, + pub(crate) sample_queue_capacity: u64, + pub(crate) persistence_queue_depth: u64, + pub(crate) indexed_documents: u64, + pub(crate) disk_available_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub(crate) struct DiagnosticsStatus { + pub(crate) enabled: bool, + pub(crate) persisted_samples: u64, + pub(crate) dropped_samples: u64, + pub(crate) skipped_samples: u64, + pub(crate) write_failures: u64, + pub(crate) last_persisted_at: Option, + pub(crate) last_error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct CurrentDiagnosticsResponse { + pub(crate) status: DiagnosticsStatus, + pub(crate) sample: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct DiagnosticHistory { + pub(crate) resolution: &'static str, + pub(crate) from: u64, + pub(crate) to: u64, + pub(crate) samples: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HistoryResolution { + Raw, + Minute, +} + +impl HistoryResolution { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Raw => "raw", + Self::Minute => "minute", + } + } + + pub(crate) const fn database_value(self) -> i64 { + match self { + Self::Raw => 0, + Self::Minute => 1, + } + } +} diff --git a/src/search/src/diagnostics/process.rs b/src/search/src/diagnostics/process.rs new file mode 100644 index 0000000..ef967e4 --- /dev/null +++ b/src/search/src/diagnostics/process.rs @@ -0,0 +1,167 @@ +// 负责采集当前进程内存 CPU 线程和句柄资源快照 + +use super::model::ProcessDiagnostics; + +#[cfg(windows)] +pub(crate) fn snapshot() -> ProcessDiagnostics { + use std::mem::size_of; + + use windows_sys::Win32::{ + Foundation::{CloseHandle, FILETIME, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, + Thread32Next, + }, + ProcessStatus::{ + K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS, PROCESS_MEMORY_COUNTERS_EX, + }, + Threading::{ + GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount, GetProcessTimes, + }, + }, + }; + + let process = unsafe { GetCurrentProcess() }; + let mut memory = PROCESS_MEMORY_COUNTERS_EX { + cb: size_of::() as u32, + ..PROCESS_MEMORY_COUNTERS_EX::default() + }; + let memory_ok = unsafe { + K32GetProcessMemoryInfo( + process, + (&raw mut memory).cast::(), + memory.cb, + ) != 0 + }; + + let mut handles = 0_u32; + let handles_ok = unsafe { GetProcessHandleCount(process, &raw mut handles) != 0 }; + + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + let cpu_ok = unsafe { + GetProcessTimes( + process, + &raw mut creation, + &raw mut exit, + &raw mut kernel, + &raw mut user, + ) != 0 + }; + + let result = ProcessDiagnostics { + resident_memory_bytes: memory_ok.then_some(memory.WorkingSetSize as u64), + private_memory_bytes: memory_ok.then_some(memory.PrivateUsage as u64), + cpu_time_millis: cpu_ok + .then(|| filetime_ticks(kernel).saturating_add(filetime_ticks(user)) / 10_000), + thread_count: windows_thread_count(), + handle_count: handles_ok.then_some(u64::from(handles)), + }; + + fn filetime_ticks(value: FILETIME) -> u64 { + (u64::from(value.dwHighDateTime) << 32) | u64::from(value.dwLowDateTime) + } + + fn windows_thread_count() -> Option { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return None; + } + let process_id = unsafe { GetCurrentProcessId() }; + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..THREADENTRY32::default() + }; + let mut count = 0_u64; + let mut has_entry = unsafe { Thread32First(snapshot, &raw mut entry) != 0 }; + while has_entry { + if entry.th32OwnerProcessID == process_id { + count = count.saturating_add(1); + } + has_entry = unsafe { Thread32Next(snapshot, &raw mut entry) != 0 }; + } + unsafe { + CloseHandle(snapshot); + } + Some(count) + } + + result +} + +#[cfg(target_os = "linux")] +pub(crate) fn snapshot() -> ProcessDiagnostics { + let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default(); + ProcessDiagnostics { + resident_memory_bytes: status_kib(&status, "VmRSS:"), + private_memory_bytes: status_kib(&status, "RssAnon:") + .or_else(|| status_kib(&status, "VmData:")), + cpu_time_millis: linux_cpu_time_millis(), + thread_count: status_value(&status, "Threads:"), + handle_count: std::fs::read_dir("/proc/self/fd") + .ok() + .map(|entries| entries.count().min(u64::MAX as usize) as u64), + } +} + +#[cfg(target_os = "linux")] +fn status_kib(status: &str, key: &str) -> Option { + status_value(status, key).map(|value| value.saturating_mul(1_024)) +} + +#[cfg(target_os = "linux")] +fn status_value(status: &str, key: &str) -> Option { + status + .lines() + .find_map(|line| line.strip_prefix(key))? + .split_whitespace() + .next()? + .parse() + .ok() +} + +#[cfg(target_os = "linux")] +fn linux_cpu_time_millis() -> Option { + let stat = std::fs::read_to_string("/proc/self/stat").ok()?; + let fields = stat.get(stat.rfind(')')?.saturating_add(2)..)?; + let mut fields = fields.split_whitespace(); + let user_ticks: u64 = fields.nth(11)?.parse().ok()?; + let system_ticks: u64 = fields.next()?.parse().ok()?; + let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if ticks_per_second <= 0 { + return None; + } + Some( + user_ticks + .saturating_add(system_ticks) + .saturating_mul(1_000) + / ticks_per_second as u64, + ) +} + +#[cfg(not(any(windows, target_os = "linux")))] +pub(crate) fn snapshot() -> ProcessDiagnostics { + ProcessDiagnostics::default() +} + +#[cfg(test)] +mod tests { + #[test] + fn current_process_reports_available_platform_resources() { + let snapshot = super::snapshot(); + #[cfg(any(windows, target_os = "linux"))] + { + assert!( + snapshot + .resident_memory_bytes + .is_some_and(|value| value > 0) + ); + assert!(snapshot.cpu_time_millis.is_some()); + assert!(snapshot.thread_count.is_some_and(|value| value > 0)); + assert!(snapshot.handle_count.is_some_and(|value| value > 0)); + } + } +} diff --git a/src/search/src/diagnostics/store.rs b/src/search/src/diagnostics/store.rs new file mode 100644 index 0000000..015c37c --- /dev/null +++ b/src/search/src/diagnostics/store.rs @@ -0,0 +1,209 @@ +// 负责使用独立 SQLite 数据库持久化和查询有界诊断历史 + +use std::{fs, path::Path}; + +use rusqlite::{Connection, OpenFlags, params}; + +use crate::config::DiagnosticsConfig; + +use super::model::{DiagnosticHistory, DiagnosticSample, HistoryResolution}; + +const DATABASE_VERSION: i64 = 1; +const MAX_HISTORY_SAMPLES: usize = 50_000; + +pub(crate) struct DiagnosticStore { + connection: Connection, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum DiagnosticStoreError { + #[error("无法准备诊断数据库目录: {0}")] + Io(#[from] std::io::Error), + #[error("SQLite 诊断数据库操作失败: {0}")] + Sqlite(#[from] rusqlite::Error), + #[error("诊断采样序列化失败: {0}")] + Json(#[from] serde_json::Error), + #[error("诊断数据库格式版本不兼容")] + IncompatibleVersion, +} + +impl DiagnosticStore { + pub(crate) fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let connection = Connection::open(path)?; + configure(&connection)?; + initialize(&connection)?; + Ok(Self { connection }) + } + + pub(crate) fn record( + &mut self, + sample: &DiagnosticSample, + config: &DiagnosticsConfig, + ) -> Result<(), DiagnosticStoreError> { + let payload = serde_json::to_string(sample)?; + let transaction = self.connection.transaction()?; + transaction.execute( + "INSERT OR REPLACE INTO diagnostic_samples (kind, captured_at, payload) VALUES (0, ?1, ?2)", + params![as_i64(sample.captured_at), payload], + )?; + let minute = sample.captured_at / 60 * 60; + transaction.execute( + "INSERT OR REPLACE INTO diagnostic_samples (kind, captured_at, payload) VALUES (1, ?1, ?2)", + params![as_i64(minute), payload], + )?; + let raw_cutoff = sample + .captured_at + .saturating_sub(config.raw_retention_hours.saturating_mul(3_600)); + let minute_cutoff = sample + .captured_at + .saturating_sub(config.minute_retention_days.saturating_mul(86_400)); + transaction.execute( + "DELETE FROM diagnostic_samples WHERE kind = 0 AND captured_at < ?1", + [as_i64(raw_cutoff)], + )?; + transaction.execute( + "DELETE FROM diagnostic_samples WHERE kind = 1 AND captured_at < ?1", + [as_i64(minute_cutoff)], + )?; + transaction.commit()?; + Ok(()) + } + + pub(crate) fn history( + path: &Path, + resolution: HistoryResolution, + from: u64, + to: u64, + ) -> Result { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let mut statement = connection.prepare( + "SELECT payload FROM diagnostic_samples + WHERE kind = ?1 AND captured_at >= ?2 AND captured_at <= ?3 + ORDER BY captured_at ASC LIMIT ?4", + )?; + let rows = statement.query_map( + params![ + resolution.database_value(), + as_i64(from), + as_i64(to), + MAX_HISTORY_SAMPLES as i64 + ], + |row| row.get::<_, String>(0), + )?; + let mut samples = Vec::new(); + for payload in rows { + samples.push(serde_json::from_str(&payload?)?); + } + Ok(DiagnosticHistory { + resolution: resolution.as_str(), + from, + to, + samples, + }) + } + + #[cfg(test)] + fn count(&self, resolution: HistoryResolution) -> Result { + let count: i64 = self + .connection + .query_row( + "SELECT COUNT(*) FROM diagnostic_samples WHERE kind = ?1", + [resolution.database_value()], + |row| row.get(0), + ) + .map_err(DiagnosticStoreError::from)?; + Ok(count.max(0) as u64) + } +} + +fn configure(connection: &Connection) -> Result<(), rusqlite::Error> { + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.pragma_update(None, "synchronous", "NORMAL")?; + connection.busy_timeout(std::time::Duration::from_secs(5)) +} + +fn initialize(connection: &Connection) -> Result<(), DiagnosticStoreError> { + let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if version != 0 && version != DATABASE_VERSION { + return Err(DiagnosticStoreError::IncompatibleVersion); + } + connection.execute_batch( + "CREATE TABLE IF NOT EXISTS diagnostic_samples ( + kind INTEGER NOT NULL, + captured_at INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (kind, captured_at) + ) WITHOUT ROWID;", + )?; + connection.pragma_update(None, "user_version", DATABASE_VERSION)?; + Ok(()) +} + +fn as_i64(value: u64) -> i64 { + value.min(i64::MAX as u64) as i64 +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::diagnostics::model::{ + HttpDiagnostics, ProcessDiagnostics, RuntimeDiagnostics, SearchDiagnostics, + StorageDiagnostics, + }; + + fn sample(captured_at: u64) -> DiagnosticSample { + DiagnosticSample { + captured_at, + session_started_at: 1, + process: ProcessDiagnostics::default(), + storage: StorageDiagnostics::default(), + search: SearchDiagnostics::default(), + http: HttpDiagnostics::default(), + runtime: RuntimeDiagnostics::default(), + } + } + + #[test] + fn samples_survive_reopen_and_minute_rows_are_compacted() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("diagnostics.sqlite3"); + let config = DiagnosticsConfig { + raw_retention_hours: 1, + minute_retention_days: 1, + ..DiagnosticsConfig::default() + }; + let mut store = DiagnosticStore::open(&path).unwrap(); + store.record(&sample(3_600), &config).unwrap(); + store.record(&sample(3_610), &config).unwrap(); + assert_eq!(store.count(HistoryResolution::Raw).unwrap(), 2); + assert_eq!(store.count(HistoryResolution::Minute).unwrap(), 1); + drop(store); + + let history = DiagnosticStore::history(&path, HistoryResolution::Raw, 0, 4_000).unwrap(); + assert_eq!(history.samples, vec![sample(3_600), sample(3_610)]); + } + + #[test] + fn retention_deletes_only_expired_resolution_rows() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("diagnostics.sqlite3"); + let config = DiagnosticsConfig { + raw_retention_hours: 1, + minute_retention_days: 1, + ..DiagnosticsConfig::default() + }; + let mut store = DiagnosticStore::open(&path).unwrap(); + store.record(&sample(1), &config).unwrap(); + store.record(&sample(90_000), &config).unwrap(); + assert_eq!(store.count(HistoryResolution::Raw).unwrap(), 1); + assert_eq!(store.count(HistoryResolution::Minute).unwrap(), 1); + } +} diff --git a/dht-search/src/disk_guard.rs b/src/search/src/disk_guard.rs similarity index 100% rename from dht-search/src/disk_guard.rs rename to src/search/src/disk_guard.rs diff --git a/dht-search/src/domain/content_filter.rs b/src/search/src/domain/content_filter.rs similarity index 100% rename from dht-search/src/domain/content_filter.rs rename to src/search/src/domain/content_filter.rs diff --git a/dht-search/src/domain/content_group.rs b/src/search/src/domain/content_group.rs similarity index 93% rename from dht-search/src/domain/content_group.rs rename to src/search/src/domain/content_group.rs index c0979c4..c33a73c 100644 --- a/dht-search/src/domain/content_group.rs +++ b/src/search/src/domain/content_group.rs @@ -1,8 +1,11 @@ // 负责把相同内容的多个 infohash 聚合为稳定且可排序的搜索内容组 +#[cfg(any(feature = "rocksdb-storage", test))] use std::cmp::Ordering; -use super::{Availability, AvailabilityStatus, Heat, InfoHash, TorrentRecord}; +use super::{Availability, Heat, TorrentRecord}; +#[cfg(any(feature = "rocksdb-storage", test))] +use super::{AvailabilityStatus, InfoHash}; #[derive(Debug, Clone, PartialEq)] pub struct ContentGroup { @@ -17,6 +20,7 @@ pub struct ContentGroup { pub availability: Availability, } +#[cfg(any(feature = "rocksdb-storage", test))] pub(crate) struct ContentGroupBuilder { content_key: [u8; 32], now: u64, @@ -31,6 +35,7 @@ pub(crate) struct ContentGroupBuilder { availability_initialized: bool, } +#[cfg(any(feature = "rocksdb-storage", test))] impl ContentGroupBuilder { pub(crate) fn new(content_key: [u8; 32], now: u64) -> Self { Self { @@ -116,8 +121,10 @@ impl ContentGroupBuilder { } } +#[cfg(any(feature = "rocksdb-storage", test))] type RepresentativeRank = (u8, u8, u32, u64, u64, std::cmp::Reverse); +#[cfg(any(feature = "rocksdb-storage", test))] fn representative_rank(record: &TorrentRecord, heat: Heat) -> RepresentativeRank { let availability = match record.availability.status { AvailabilityStatus::Active => 2, @@ -134,6 +141,7 @@ fn representative_rank(record: &TorrentRecord, heat: Heat) -> RepresentativeRank ) } +#[cfg(any(feature = "rocksdb-storage", test))] fn merge_availability(target: &mut Availability, candidate: &Availability) { if availability_rank(candidate.status) > availability_rank(target.status) { target.status = candidate.status; @@ -148,6 +156,7 @@ fn merge_availability(target: &mut Availability, candidate: &Availability) { target.next_check_at = target.next_check_at.max(candidate.next_check_at); } +#[cfg(any(feature = "rocksdb-storage", test))] fn availability_rank(status: AvailabilityStatus) -> u8 { match status { AvailabilityStatus::Active => 2, diff --git a/dht-search/src/domain/fingerprint.rs b/src/search/src/domain/fingerprint.rs similarity index 100% rename from dht-search/src/domain/fingerprint.rs rename to src/search/src/domain/fingerprint.rs diff --git a/dht-search/src/domain/info_hash.rs b/src/search/src/domain/info_hash.rs similarity index 100% rename from dht-search/src/domain/info_hash.rs rename to src/search/src/domain/info_hash.rs diff --git a/dht-search/src/domain/metadata.rs b/src/search/src/domain/metadata.rs similarity index 92% rename from dht-search/src/domain/metadata.rs rename to src/search/src/domain/metadata.rs index ad52beb..94e891b 100644 --- a/dht-search/src/domain/metadata.rs +++ b/src/search/src/domain/metadata.rs @@ -2,7 +2,6 @@ use std::str::FromStr; -use dht_crawler::TorrentInfo; use serde::{Deserialize, Serialize}; use super::{ @@ -13,6 +12,17 @@ use super::{ const MAX_STORED_PEERS: usize = 32; const METADATA_VALIDATION_VERSION: u64 = 1; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetadataCandidate { + pub info_hash: String, + pub name: String, + pub total_size: u64, + pub files: Vec, + pub piece_length: u64, + pub source_peers: Vec, + pub timestamp: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MetadataLimits { pub max_files: usize, @@ -164,7 +174,7 @@ impl TorrentRecordError { } pub(crate) fn into_record( - info: TorrentInfo, + info: MetadataCandidate, limits: MetadataLimits, ) -> Result { let info_hash = InfoHash::from_str(&info.info_hash)?; @@ -181,14 +191,7 @@ pub(crate) fn into_record( for file in &info.files { validate_path(&file.path, limits)?; } - let files: Vec<_> = info - .files - .into_iter() - .map(|file| TorrentFile { - path: file.path, - size: file.size, - }) - .collect(); + let files = info.files; let calculated_size = files.iter().try_fold(0_u64, |total, file| { total .checked_add(file.size) @@ -201,7 +204,7 @@ pub(crate) fn into_record( }); } let content_key = content_key(&files)?; - let mut source_peers = info.peers; + let mut source_peers = info.source_peers; source_peers.sort_unstable(); source_peers.dedup(); source_peers.truncate(MAX_STORED_PEERS); @@ -278,31 +281,28 @@ fn validate_path(path: &str, limits: MetadataLimits) -> Result<(), TorrentRecord Ok(()) } -impl TryFrom for TorrentRecord { +impl TryFrom for TorrentRecord { type Error = TorrentRecordError; - fn try_from(info: TorrentInfo) -> Result { + fn try_from(info: MetadataCandidate) -> Result { into_record(info, MetadataLimits::default()) } } #[cfg(test)] mod tests { - use dht_crawler::FileInfo; - use super::*; - fn torrent_info(files: Vec) -> TorrentInfo { - TorrentInfo { + fn metadata_candidate(files: Vec) -> MetadataCandidate { + MetadataCandidate { info_hash: "0101010101010101010101010101010101010101".into(), - magnet_link: String::new(), name: "Example".into(), total_size: files .iter() .fold(0_u64, |total, file| total.saturating_add(file.size)), files, piece_length: 16_384, - peers: Vec::new(), + source_peers: Vec::new(), timestamp: 1, } } @@ -315,17 +315,17 @@ mod tests { max_path_bytes: 8, max_path_depth: 2, }; - let accepted = torrent_info(vec![FileInfo { + let accepted = metadata_candidate(vec![TorrentFile { path: "dir/a.rs".into(), size: 1, }]); assert!(into_record(accepted, limits).is_ok()); - let too_many = torrent_info(vec![ - FileInfo { + let too_many = metadata_candidate(vec![ + TorrentFile { path: "a".into(), size: 1, }, - FileInfo { + TorrentFile { path: "b".into(), size: 1, }, @@ -350,7 +350,7 @@ mod tests { ("a/b/c", MetadataRejectionReason::PathTooDeep), ("a\0b", MetadataRejectionReason::InvalidPath), ] { - let info = torrent_info(vec![FileInfo { + let info = metadata_candidate(vec![TorrentFile { path: path.into(), size: 1, }]); @@ -363,12 +363,12 @@ mod tests { #[test] fn size_overflow_is_rejected_without_panicking() { - let mut info = torrent_info(vec![ - FileInfo { + let mut info = metadata_candidate(vec![ + TorrentFile { path: "a".into(), size: u64::MAX, }, - FileInfo { + TorrentFile { path: "b".into(), size: 1, }, diff --git a/dht-search/src/domain/mod.rs b/src/search/src/domain/mod.rs similarity index 79% rename from dht-search/src/domain/mod.rs rename to src/search/src/domain/mod.rs index 2ca079c..a232dde 100644 --- a/dht-search/src/domain/mod.rs +++ b/src/search/src/domain/mod.rs @@ -12,10 +12,14 @@ pub use content_filter::{ FileMatchKind, FileRuleAction, FilterOutcome, }; pub use content_group::ContentGroup; +#[cfg(any(feature = "rocksdb-storage", test))] pub(crate) use content_group::ContentGroupBuilder; pub use fingerprint::content_key; pub use info_hash::InfoHash; -pub use metadata::{MetadataLimits, MetadataRejectionReason, RejectedMetadata, TorrentRecordError}; +pub use metadata::{ + MetadataCandidate, MetadataLimits, MetadataRejectionReason, RejectedMetadata, + TorrentRecordError, +}; #[cfg(test)] pub(crate) use torrent::test_record; pub use torrent::{ diff --git a/dht-search/src/domain/torrent.rs b/src/search/src/domain/torrent.rs similarity index 96% rename from dht-search/src/domain/torrent.rs rename to src/search/src/domain/torrent.rs index c997e2b..8c83aa7 100644 --- a/dht-search/src/domain/torrent.rs +++ b/src/search/src/domain/torrent.rs @@ -1,11 +1,10 @@ // 负责定义种子记录活跃度可用性和重复发现时的状态演进 -use dht_crawler::TorrentInfo; use serde::{Deserialize, Serialize}; #[cfg(test)] use super::content_key; -use super::{InfoHash, MetadataLimits, TorrentRecordError}; +use super::{InfoHash, MetadataCandidate, MetadataLimits, TorrentRecordError}; const MAX_STORED_PEERS: usize = 32; const ACTIVITY_SCALE: u64 = 1_000; @@ -126,7 +125,7 @@ impl TorrentRecord { } pub fn try_from_with_limits( - info: TorrentInfo, + info: MetadataCandidate, limits: MetadataLimits, ) -> Result { super::metadata::into_record(info, limits) @@ -249,8 +248,6 @@ pub(crate) fn test_record(hash_byte: u8, timestamp: u64) -> TorrentRecord { #[cfg(test)] mod tests { - use dht_crawler::FileInfo; - use super::*; #[test] @@ -296,17 +293,16 @@ mod tests { #[test] fn freshly_downloaded_metadata_is_immediately_active() { - let record = TorrentRecord::try_from(TorrentInfo { + let record = TorrentRecord::try_from(MetadataCandidate { info_hash: "0101010101010101010101010101010101010101".into(), - magnet_link: String::new(), name: "Example".into(), total_size: 42, - files: vec![FileInfo { + files: vec![TorrentFile { path: "example.bin".into(), size: 42, }], piece_length: 16_384, - peers: vec!["127.0.0.1:6881".into()], + source_peers: vec!["127.0.0.1:6881".into()], timestamp: 100, }) .unwrap(); diff --git a/dht-search/src/main.rs b/src/search/src/entry.rs similarity index 70% rename from dht-search/src/main.rs rename to src/search/src/entry.rs index 100a533..392614e 100644 --- a/dht-search/src/main.rs +++ b/src/search/src/entry.rs @@ -1,27 +1,15 @@ -// 负责组装应用依赖启动运行时并协调服务生命周期 +// 负责加载启动配置初始化日志并选择正常运行或离线恢复模式 use clap::Parser; -mod api; -mod app; -mod backup; -mod config; -mod crawler; -mod disk_guard; -mod error; -mod index_worker; -mod monitor; -mod shutdown; -mod telemetry; -mod verification; +use crate::{backup, config, error, telemetry}; -#[tokio::main] -async fn main() { +pub async fn run_cli() -> std::process::ExitCode { let startup = match config::Cli::parse().load() { Ok(config) => config, Err(error) => { eprintln!("无法加载配置: {error}"); - std::process::exit(1); + return std::process::ExitCode::FAILURE; } }; let restore_requested = startup.restore_checkpoint.is_some(); @@ -34,7 +22,7 @@ async fn main() { Ok(guard) => guard, Err(error) => { eprintln!("无法初始化日志: {error}"); - std::process::exit(1); + return std::process::ExitCode::FAILURE; } }; let result = if let Some(checkpoint) = startup.restore_checkpoint { @@ -52,10 +40,13 @@ async fn main() { }) .map_err(error::AppError::from) } else { - app::run(startup.app).await + crate::app::run(startup.app, startup.config_service).await }; - if let Err(error) = result { - tracing::error!(%error, "dht-search 退出"); - std::process::exit(1); + match result { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + tracing::error!(%error, "dht-search 退出"); + std::process::ExitCode::FAILURE + } } } diff --git a/dht-search/src/error.rs b/src/search/src/error.rs similarity index 74% rename from dht-search/src/error.rs rename to src/search/src/error.rs index 24649af..06057be 100644 --- a/dht-search/src/error.rs +++ b/src/search/src/error.rs @@ -6,8 +6,10 @@ pub(crate) enum AppError { Io(#[from] std::io::Error), #[error("配置解析失败: {0}")] Toml(#[from] toml::de::Error), + #[error("配置序列化失败: {0}")] + TomlSerialize(#[from] toml::ser::Error), #[error("内容过滤配置无效: {0}")] - ContentFilter(#[from] dht_search::domain::ContentFilterError), + ContentFilter(#[from] crate::domain::ContentFilterError), #[error("备份或恢复失败: {0}")] Backup(#[from] crate::backup::BackupError), #[error("配置无效: {0}")] @@ -15,9 +17,9 @@ pub(crate) enum AppError { #[error("DHT 服务失败: {0}")] Dht(#[from] dht_crawler::DHTError), #[error("存储失败: {0}")] - Storage(#[from] dht_search::storage::StorageError), + Storage(#[from] crate::storage::StorageError), #[error("搜索失败: {0}")] - Search(#[from] dht_search::search::SearchError), + Search(#[from] crate::search::SearchError), #[error("持久化 worker 异常退出")] PersistenceWorkerPanicked, #[error("持久化 worker 失败: {0}")] @@ -26,4 +28,6 @@ pub(crate) enum AppError { IndexWorker(String), #[error("可用性验证 worker 失败: {0}")] VerificationWorker(String), + #[error("运行诊断失败: {0}")] + Diagnostics(String), } diff --git a/dht-search/src/index_worker.rs b/src/search/src/index_worker.rs similarity index 99% rename from dht-search/src/index_worker.rs rename to src/search/src/index_worker.rs index 4fab9a8..b2a1f27 100644 --- a/dht-search/src/index_worker.rs +++ b/src/search/src/index_worker.rs @@ -2,7 +2,7 @@ use std::{sync::Arc, time::Duration}; -use dht_search::{ +use crate::{ search::SearchEngine, storage::{RocksTorrentRepository, TorrentRepository}, }; diff --git a/src/search/src/lib.rs b/src/search/src/lib.rs new file mode 100644 index 0000000..b1b2330 --- /dev/null +++ b/src/search/src/lib.rs @@ -0,0 +1,36 @@ +// 负责导出可测试核心能力并组合应用运行入口 + +#[cfg(feature = "rocksdb-storage")] +mod api; +#[cfg(feature = "rocksdb-storage")] +mod app; +#[cfg(feature = "rocksdb-storage")] +mod backup; +#[cfg(feature = "rocksdb-storage")] +mod config; +#[cfg(feature = "rocksdb-storage")] +mod crawler; +#[cfg(feature = "rocksdb-storage")] +mod diagnostics; +#[cfg(feature = "rocksdb-storage")] +mod disk_guard; +pub mod domain; +#[cfg(feature = "rocksdb-storage")] +mod entry; +#[cfg(feature = "rocksdb-storage")] +mod error; +#[cfg(feature = "rocksdb-storage")] +mod index_worker; +#[cfg(feature = "rocksdb-storage")] +mod monitor; +pub mod search; +#[cfg(feature = "rocksdb-storage")] +mod shutdown; +pub mod storage; +#[cfg(feature = "rocksdb-storage")] +mod telemetry; +#[cfg(feature = "rocksdb-storage")] +mod verification; + +#[cfg(feature = "rocksdb-storage")] +pub use entry::run_cli; diff --git a/src/search/src/main.rs b/src/search/src/main.rs new file mode 100644 index 0000000..acbcfb8 --- /dev/null +++ b/src/search/src/main.rs @@ -0,0 +1,6 @@ +// 负责进入异步运行时并把退出状态返回给操作系统 + +#[tokio::main] +async fn main() -> std::process::ExitCode { + dht_search::run_cli().await +} diff --git a/dht-search/src/monitor.rs b/src/search/src/monitor.rs similarity index 99% rename from dht-search/src/monitor.rs rename to src/search/src/monitor.rs index 3e67cbd..12a01fb 100644 --- a/dht-search/src/monitor.rs +++ b/src/search/src/monitor.rs @@ -2,8 +2,8 @@ use std::time::Duration; +use crate::domain::MetadataRejectionReason; use dht_crawler::DHTServer; -use dht_search::domain::MetadataRejectionReason; use tokio_util::sync::CancellationToken; use crate::{crawler::pipeline::PersistenceIngress, disk_guard::DiskGuard}; diff --git a/dht-search/src/search/document.rs b/src/search/src/search/document.rs similarity index 100% rename from dht-search/src/search/document.rs rename to src/search/src/search/document.rs diff --git a/dht-search/src/search/filter.rs b/src/search/src/search/filter.rs similarity index 100% rename from dht-search/src/search/filter.rs rename to src/search/src/search/filter.rs diff --git a/dht-search/src/search/indexer.rs b/src/search/src/search/indexer.rs similarity index 85% rename from dht-search/src/search/indexer.rs rename to src/search/src/search/indexer.rs index 7904c27..bdbf7f4 100644 --- a/dht-search/src/search/indexer.rs +++ b/src/search/src/search/indexer.rs @@ -2,7 +2,11 @@ use std::{ path::Path, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering as AtomicOrdering}, + }, + time::Instant, }; use crate::domain::ContentGroup; @@ -31,10 +35,26 @@ pub struct SearchEngine { inner: Arc, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SearchDiagnostics { + pub documents: u64, + pub writer_memory_budget_bytes: u64, + pub commits: u64, + pub commit_failures: u64, + pub last_commit_at: Option, + pub last_commit_duration_millis: u64, + pub last_commit_documents: u64, +} + struct SearchInner { reader: IndexReader, writer: Mutex, fields: SearchFields, + commits: AtomicU64, + commit_failures: AtomicU64, + last_commit_at: AtomicU64, + last_commit_duration_millis: AtomicU64, + last_commit_documents: AtomicU64, } impl SearchEngine { @@ -94,6 +114,11 @@ impl SearchEngine { reader, writer: Mutex::new(writer), fields, + commits: AtomicU64::new(0), + commit_failures: AtomicU64::new(0), + last_commit_at: AtomicU64::new(0), + last_commit_duration_millis: AtomicU64::new(0), + last_commit_documents: AtomicU64::new(0), }), }, created, @@ -104,28 +129,69 @@ impl SearchEngine { if groups.is_empty() { return Ok(()); } + let started = Instant::now(); let fields = self.inner.fields; - let mut writer = self - .inner - .writer - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - for group in groups { - writer.delete_term(Term::from_field_text( - fields.content_key, - &hex::encode(group.content_key), - )); - writer.add_document(super::document::from_group(group, fields))?; + let result = (|| { + let mut writer = self + .inner + .writer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for group in groups { + writer.delete_term(Term::from_field_text( + fields.content_key, + &hex::encode(group.content_key), + )); + writer.add_document(super::document::from_group(group, fields))?; + } + writer.commit()?; + self.inner.reader.reload()?; + Ok(()) + })(); + self.inner.last_commit_duration_millis.store( + started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64, + AtomicOrdering::Relaxed, + ); + if result.is_ok() { + self.inner.commits.fetch_add(1, AtomicOrdering::Relaxed); + self.inner + .last_commit_at + .store(unix_timestamp(), AtomicOrdering::Relaxed); + self.inner.last_commit_documents.store( + groups.len().min(u64::MAX as usize) as u64, + AtomicOrdering::Relaxed, + ); + } else { + self.inner + .commit_failures + .fetch_add(1, AtomicOrdering::Relaxed); } - writer.commit()?; - self.inner.reader.reload()?; - Ok(()) + result } pub fn num_docs(&self) -> u64 { self.inner.reader.searcher().num_docs() } + pub fn diagnostics(&self) -> SearchDiagnostics { + let last_commit_at = self.inner.last_commit_at.load(AtomicOrdering::Relaxed); + SearchDiagnostics { + documents: self.num_docs(), + writer_memory_budget_bytes: INDEX_WRITER_MEMORY_BYTES as u64, + commits: self.inner.commits.load(AtomicOrdering::Relaxed), + commit_failures: self.inner.commit_failures.load(AtomicOrdering::Relaxed), + last_commit_at: (last_commit_at != 0).then_some(last_commit_at), + last_commit_duration_millis: self + .inner + .last_commit_duration_millis + .load(AtomicOrdering::Relaxed), + last_commit_documents: self + .inner + .last_commit_documents + .load(AtomicOrdering::Relaxed), + } + } + pub fn index_pending( &self, repository: &dyn TorrentRepository, @@ -263,7 +329,6 @@ fn sorted_documents( .collect()) } -#[cfg(test)] fn unix_timestamp() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/dht-search/src/search/mod.rs b/src/search/src/search/mod.rs similarity index 97% rename from dht-search/src/search/mod.rs rename to src/search/src/search/mod.rs index 6e297d0..91cfe28 100644 --- a/dht-search/src/search/mod.rs +++ b/src/search/src/search/mod.rs @@ -6,7 +6,7 @@ mod indexer; mod query; mod schema; -pub use indexer::SearchEngine; +pub use indexer::{SearchDiagnostics, SearchEngine}; pub use query::{ AvailabilitySummary, SearchHit, SearchMode, SearchOptions, SearchPage, SearchSort, }; diff --git a/dht-search/src/search/query.rs b/src/search/src/search/query.rs similarity index 100% rename from dht-search/src/search/query.rs rename to src/search/src/search/query.rs diff --git a/dht-search/src/search/schema.rs b/src/search/src/search/schema.rs similarity index 100% rename from dht-search/src/search/schema.rs rename to src/search/src/search/schema.rs diff --git a/dht-search/src/shutdown.rs b/src/search/src/shutdown.rs similarity index 100% rename from dht-search/src/shutdown.rs rename to src/search/src/shutdown.rs diff --git a/dht-search/src/storage/keys.rs b/src/search/src/storage/keys.rs similarity index 100% rename from dht-search/src/storage/keys.rs rename to src/search/src/storage/keys.rs diff --git a/dht-search/src/storage/mod.rs b/src/search/src/storage/mod.rs similarity index 63% rename from dht-search/src/storage/mod.rs rename to src/search/src/storage/mod.rs index 070a13f..543729f 100644 --- a/dht-search/src/storage/mod.rs +++ b/src/search/src/storage/mod.rs @@ -1,13 +1,15 @@ // 负责暴露持久化抽象并隐藏 RocksDB 的具体实现细节 +#[cfg(feature = "rocksdb-storage")] mod keys; mod repository; #[cfg(feature = "rocksdb-storage")] mod rocks; pub use repository::{ - CheckpointSummary, ContentGroupTask, ContentVariants, StorageError, TorrentRepository, - UpsertOutcome, VerificationEnqueueOutcome, VerificationPriority, VerificationRequest, + CheckpointSummary, ContentGroupTask, ContentVariants, StorageDiagnostics, StorageError, + TorrentRepository, UpsertOutcome, VerificationEnqueueOutcome, VerificationPriority, + VerificationRequest, }; #[cfg(feature = "rocksdb-storage")] pub use rocks::RocksTorrentRepository; diff --git a/dht-search/src/storage/repository.rs b/src/search/src/storage/repository.rs similarity index 92% rename from dht-search/src/storage/repository.rs rename to src/search/src/storage/repository.rs index 79e21bf..b9dedc8 100644 --- a/dht-search/src/storage/repository.rs +++ b/src/search/src/storage/repository.rs @@ -97,6 +97,16 @@ pub struct CheckpointSummary { pub records: u64, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct StorageDiagnostics { + pub block_cache_bytes: Option, + pub memtable_bytes: Option, + pub pending_compaction_bytes: Option, + pub live_sst_bytes: Option, + pub running_compactions: Option, + pub estimated_keys: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VerificationPriority { Normal, diff --git a/dht-search/src/storage/rocks.rs b/src/search/src/storage/rocks.rs similarity index 98% rename from dht-search/src/storage/rocks.rs rename to src/search/src/storage/rocks.rs index a37ffe0..0f60d9a 100644 --- a/dht-search/src/storage/rocks.rs +++ b/src/search/src/storage/rocks.rs @@ -27,8 +27,9 @@ use super::{ verification_locator_key, verification_task_key, verification_task_prefix, }, repository::{ - CheckpointSummary, ContentGroupTask, ContentVariants, StorageError, TorrentRepository, - UpsertOutcome, VerificationEnqueueOutcome, VerificationPriority, VerificationRequest, + CheckpointSummary, ContentGroupTask, ContentVariants, StorageDiagnostics, StorageError, + TorrentRepository, UpsertOutcome, VerificationEnqueueOutcome, VerificationPriority, + VerificationRequest, }, }; @@ -98,6 +99,23 @@ impl RocksTorrentRepository { self.content_filter_changed } + pub fn diagnostics(&self) -> Result { + Ok(StorageDiagnostics { + block_cache_bytes: self.db.property_int_value("rocksdb.block-cache-usage")?, + memtable_bytes: self + .db + .property_int_value("rocksdb.cur-size-all-mem-tables")?, + pending_compaction_bytes: self + .db + .property_int_value("rocksdb.estimate-pending-compaction-bytes")?, + live_sst_bytes: self.db.property_int_value("rocksdb.live-sst-files-size")?, + running_compactions: self + .db + .property_int_value("rocksdb.num-running-compactions")?, + estimated_keys: self.db.property_int_value("rocksdb.estimate-num-keys")?, + }) + } + pub fn create_checkpoint(&self, path: impl AsRef) -> Result<(), StorageError> { Checkpoint::new(&self.db)?.create_checkpoint(path)?; Ok(()) diff --git a/dht-search/src/telemetry.rs b/src/search/src/telemetry.rs similarity index 100% rename from dht-search/src/telemetry.rs rename to src/search/src/telemetry.rs diff --git a/dht-search/src/verification.rs b/src/search/src/verification.rs similarity index 99% rename from dht-search/src/verification.rs rename to src/search/src/verification.rs index 854f737..bab07a3 100644 --- a/dht-search/src/verification.rs +++ b/src/search/src/verification.rs @@ -7,11 +7,11 @@ use std::sync::{ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::{collections::HashSet, net::SocketAddr}; -use dht_crawler::DHTServer; -use dht_search::{ +use crate::{ domain::{InfoHash, VerificationResult}, storage::{RocksTorrentRepository, TorrentRepository, VerificationPriority}, }; +use dht_crawler::DHTServer; use tokio::task::{JoinHandle, JoinSet}; use tokio_util::sync::CancellationToken; @@ -125,7 +125,7 @@ impl VerificationIngress { let outcome = repository.enqueue_verification(&hashes, priority, unix_timestamp(), capacity)?; let queue_len = repository.verification_queue_len()?; - Ok::<_, dht_search::storage::StorageError>((outcome, queue_len)) + Ok::<_, crate::storage::StorageError>((outcome, queue_len)) }) .await; match result { diff --git a/dht-search/tests/storage_search_flow.rs b/src/search/tests/storage_search_flow.rs similarity index 89% rename from dht-search/tests/storage_search_flow.rs rename to src/search/tests/storage_search_flow.rs index d2ecc21..b528b21 100644 --- a/dht-search/tests/storage_search_flow.rs +++ b/src/search/tests/storage_search_flow.rs @@ -2,11 +2,10 @@ #![cfg(feature = "rocksdb-storage")] -use dht_crawler::{FileInfo, TorrentInfo}; use dht_search::{ domain::{ ContentFilter, ContentFilterConfig, FileFilterRule, FileMatchField, FileMatchKind, - FileRuleAction, TorrentRecord, + FileRuleAction, MetadataCandidate, TorrentFile, TorrentRecord, }, search::SearchEngine, storage::{RocksTorrentRepository, TorrentRepository, UpsertOutcome}, @@ -19,17 +18,16 @@ fn public_components_compose_into_a_restart_safe_search_flow() { let directory = TempDir::new().unwrap(); let rocksdb = directory.path().join("rocksdb"); let tantivy = directory.path().join("tantivy"); - let record = TorrentRecord::try_from(TorrentInfo { + let record = TorrentRecord::try_from(MetadataCandidate { info_hash: "1212121212121212121212121212121212121212".into(), - magnet_link: String::new(), name: "Public API 测试资源".into(), total_size: 42, - files: vec![FileInfo { + files: vec![TorrentFile { path: "docs/public-api.txt".into(), size: 42, }], piece_length: 16_384, - peers: Vec::new(), + source_peers: Vec::new(), timestamp: 100, }) .unwrap(); @@ -76,23 +74,22 @@ fn content_filter_excludes_padding_from_search_and_visible_details() { filter, ) .unwrap(); - let record = TorrentRecord::try_from(TorrentInfo { + let record = TorrentRecord::try_from(MetadataCandidate { info_hash: "3434343434343434343434343434343434343434".into(), - magnet_link: String::new(), name: "Filtered Movie".into(), total_size: 142, files: vec![ - FileInfo { + TorrentFile { path: "movie.mkv".into(), size: 42, }, - FileInfo { + TorrentFile { path: "_____padding_file_1_请升级____".into(), size: 100, }, ], piece_length: 16_384, - peers: Vec::new(), + source_peers: Vec::new(), timestamp: 100, }) .unwrap(); diff --git a/web/.gitignore b/src/web/.gitignore similarity index 100% rename from web/.gitignore rename to src/web/.gitignore diff --git a/web/README.md b/src/web/README.md similarity index 71% rename from web/README.md rename to src/web/README.md index f07077a..396c4fc 100644 --- a/web/README.md +++ b/src/web/README.md @@ -8,20 +8,20 @@ ```powershell $env:LIBCLANG_PATH = "D:\tools\dht\.tools\libclang\clang\native" -cargo run -p dht-search -- --config dht-search.example.toml +cargo run -p dht-search --bin dht-search -- --config dht-search.toml ``` 然后打开另一个终端启动前端开发服务 ```powershell -cd web +cd src/web bun install bun run dev ``` 打开 `http://127.0.0.1:5173` -Vite 会将搜索详情状态等接口代理到 `http://127.0.0.1:8080` +Vite 会将搜索详情状态诊断和配置接口代理到 `http://127.0.0.1:8080` 可以通过 `DHT_API_TARGET` 环境变量覆盖目标 ## 检查和构建 @@ -29,18 +29,18 @@ Vite 会将搜索详情状态等接口代理到 `http://127.0.0.1:8080` bun run check ``` -生产静态资源输出到 `web/dist` +生产静态资源输出到 `src/web/dist` ## 生产运行 在启动 Rust 服务前构建一次 Web 资源 ```powershell -cd web +cd src/web bun install --frozen-lockfile bun run build -cd .. -cargo run --release -p dht-search -- --config dht-search.example.toml +cd ../.. +cargo run --release -p dht-search --bin dht-search -- --config dht-search.toml ``` 打开 `http://127.0.0.1:8080` @@ -60,6 +60,10 @@ Axum 根据配置中的 `http.web_dir` 提供静态资源和单页回退 不需 - 磁力链接打开和复制 - DHT 采集 索引 持久化和验证运行状态 - 每秒自动刷新的运行状态和点击外部关闭 +- 独立的进程 RocksDB Tantivy DHT 和队列诊断页面 +- 使用懒加载 ECharts 展示内存存储压力 Metadata 吞吐以及 HTTP 请求错误趋势 +- 按职责分组的完整配置查看编辑校验保存和重启提示 +- 搜索诊断配置使用互不冲突的单页路由 - 加载 空结果 接口错误 重试和移动端适配 ## 添加组件 diff --git a/web/bun.lock b/src/web/bun.lock similarity index 95% rename from web/bun.lock rename to src/web/bun.lock index e317af6..c8b7c3d 100644 --- a/web/bun.lock +++ b/src/web/bun.lock @@ -8,9 +8,12 @@ "@lucide/vue": "^1.29.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "echarts": "^6.1.0", "reka-ui": "^2.10.1", "tailwind-merge": "^3.6.0", "vue": "^3.5.40", + "vue-echarts": "^8.1.0", + "vue-router": "^4.6.4", }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", @@ -146,6 +149,8 @@ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], + "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + "@vue/language-core": ["@vue/language-core@3.3.9", "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.9.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ=="], "@vue/reactivity": ["@vue/reactivity@3.5.41", "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.41.tgz", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="], @@ -180,6 +185,8 @@ "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "echarts": ["echarts@6.1.0", "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], + "enhanced-resolve": ["enhanced-resolve@5.24.5", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="], "entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -248,7 +255,7 @@ "tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tslib": ["tslib@2.3.0", "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "tw-animate-css": ["tw-animate-css@1.4.0", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.4.0.tgz", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -264,8 +271,16 @@ "vue-demi": ["vue-demi@0.14.10", "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="], + "vue-echarts": ["vue-echarts@8.1.0", "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.1.0.tgz", { "peerDependencies": { "echarts": "^6.0.0", "vue": "^3.3.0" } }, "sha512-/uJVwijy3M2vIZ0NcPDgdZVqgboc6zZtC/vERfESau0eFpkHOIujj0sIiMiLP4kztQrckMFnDYWrid+gLI+IOg=="], + + "vue-router": ["vue-router@4.6.4", "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], + "vue-tsc": ["vue-tsc@3.3.9", "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.9.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.9" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA=="], + "zrender": ["zrender@6.1.0", "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="], + + "@swc/helpers/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], @@ -280,6 +295,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "aria-hidden/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], diff --git a/web/components.json b/src/web/components.json similarity index 100% rename from web/components.json rename to src/web/components.json diff --git a/web/index.html b/src/web/index.html similarity index 100% rename from web/index.html rename to src/web/index.html diff --git a/web/package.json b/src/web/package.json similarity index 88% rename from web/package.json rename to src/web/package.json index 85270b9..404868f 100644 --- a/web/package.json +++ b/src/web/package.json @@ -15,9 +15,12 @@ "@lucide/vue": "^1.29.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "echarts": "^6.1.0", "reka-ui": "^2.10.1", "tailwind-merge": "^3.6.0", - "vue": "^3.5.40" + "vue": "^3.5.40", + "vue-echarts": "^8.1.0", + "vue-router": "^4.6.4" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", diff --git a/web/public/favicon.svg b/src/web/public/favicon.svg similarity index 100% rename from web/public/favicon.svg rename to src/web/public/favicon.svg diff --git a/web/public/icons.svg b/src/web/public/icons.svg similarity index 100% rename from web/public/icons.svg rename to src/web/public/icons.svg diff --git a/src/web/src/App.vue b/src/web/src/App.vue new file mode 100644 index 0000000..254ad53 --- /dev/null +++ b/src/web/src/App.vue @@ -0,0 +1,101 @@ + + + diff --git a/web/src/components/SearchResultCard.vue b/src/web/src/components/SearchResultCard.vue similarity index 100% rename from web/src/components/SearchResultCard.vue rename to src/web/src/components/SearchResultCard.vue diff --git a/web/src/components/TorrentDetailDialog.vue b/src/web/src/components/TorrentDetailDialog.vue similarity index 100% rename from web/src/components/TorrentDetailDialog.vue rename to src/web/src/components/TorrentDetailDialog.vue diff --git a/web/src/components/ui/button/Button.vue b/src/web/src/components/ui/button/Button.vue similarity index 100% rename from web/src/components/ui/button/Button.vue rename to src/web/src/components/ui/button/Button.vue diff --git a/web/src/components/ui/button/index.ts b/src/web/src/components/ui/button/index.ts similarity index 100% rename from web/src/components/ui/button/index.ts rename to src/web/src/components/ui/button/index.ts diff --git a/web/src/components/ui/pagination/AppPagination.vue b/src/web/src/components/ui/pagination/AppPagination.vue similarity index 100% rename from web/src/components/ui/pagination/AppPagination.vue rename to src/web/src/components/ui/pagination/AppPagination.vue diff --git a/web/src/components/ui/pagination/index.ts b/src/web/src/components/ui/pagination/index.ts similarity index 100% rename from web/src/components/ui/pagination/index.ts rename to src/web/src/components/ui/pagination/index.ts diff --git a/web/src/components/ui/select/AppSelect.vue b/src/web/src/components/ui/select/AppSelect.vue similarity index 100% rename from web/src/components/ui/select/AppSelect.vue rename to src/web/src/components/ui/select/AppSelect.vue diff --git a/web/src/components/ui/select/index.ts b/src/web/src/components/ui/select/index.ts similarity index 100% rename from web/src/components/ui/select/index.ts rename to src/web/src/components/ui/select/index.ts diff --git a/web/src/lib/api.ts b/src/web/src/lib/api.ts similarity index 57% rename from web/src/lib/api.ts rename to src/web/src/lib/api.ts index cd02aac..7006396 100644 --- a/web/src/lib/api.ts +++ b/src/web/src/lib/api.ts @@ -1,5 +1,9 @@ import type { ContentVariants, + ConfigSnapshot, + ConfigUpdateRequest, + CurrentDiagnostics, + DiagnosticHistory, SearchPage, SearchSort, ServiceStats, @@ -14,8 +18,12 @@ interface SearchInput { regex: boolean } -async function request(path: string, signal?: AbortSignal): Promise { - const response = await fetch(path, { headers: { Accept: 'application/json' }, signal }) +async function request(path: string, signal?: AbortSignal, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { Accept: 'application/json', ...init?.headers }, + signal, + }) if (!response.ok) { let message = `请求失败 (${response.status})` try { @@ -52,3 +60,24 @@ export function getVariants(contentKey: string, signal?: AbortSignal): Promise { return request('/stats', signal) } + +export function getCurrentDiagnostics(signal?: AbortSignal): Promise { + return request('/diagnostics/current', signal) +} + +export function getDiagnosticHistory(rangeSecs: number, resolution: 'raw' | 'minute', signal?: AbortSignal): Promise { + const params = new URLSearchParams({ range_secs: String(rangeSecs), resolution }) + return request(`/diagnostics/history?${params}`, signal) +} + +export function getConfig(signal?: AbortSignal): Promise { + return request('/config', signal) +} + +export function updateConfig(input: ConfigUpdateRequest, signal?: AbortSignal): Promise { + return request('/config', signal, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) +} diff --git a/web/src/lib/format.ts b/src/web/src/lib/format.ts similarity index 100% rename from web/src/lib/format.ts rename to src/web/src/lib/format.ts diff --git a/web/src/lib/utils.ts b/src/web/src/lib/utils.ts similarity index 100% rename from web/src/lib/utils.ts rename to src/web/src/lib/utils.ts diff --git a/web/src/main.ts b/src/web/src/main.ts similarity index 53% rename from web/src/main.ts rename to src/web/src/main.ts index 2425c0f..7b19912 100644 --- a/web/src/main.ts +++ b/src/web/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import './style.css' import App from './App.vue' +import router from './router' -createApp(App).mount('#app') +createApp(App).use(router).mount('#app') diff --git a/src/web/src/pages/ConfigPage.vue b/src/web/src/pages/ConfigPage.vue new file mode 100644 index 0000000..c346b7a --- /dev/null +++ b/src/web/src/pages/ConfigPage.vue @@ -0,0 +1,208 @@ + + + diff --git a/src/web/src/pages/DiagnosticsPage.vue b/src/web/src/pages/DiagnosticsPage.vue new file mode 100644 index 0000000..42bcbc3 --- /dev/null +++ b/src/web/src/pages/DiagnosticsPage.vue @@ -0,0 +1,171 @@ + + + diff --git a/web/src/App.vue b/src/web/src/pages/SearchPage.vue similarity index 68% rename from web/src/App.vue rename to src/web/src/pages/SearchPage.vue index 9760029..5df6551 100644 --- a/web/src/App.vue +++ b/src/web/src/pages/SearchPage.vue @@ -1,15 +1,14 @@ diff --git a/src/web/src/router.ts b/src/web/src/router.ts new file mode 100644 index 0000000..f48ed6c --- /dev/null +++ b/src/web/src/router.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: () => import('@/pages/SearchPage.vue') }, + { path: '/system', component: () => import('@/pages/DiagnosticsPage.vue') }, + { path: '/settings', component: () => import('@/pages/ConfigPage.vue') }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}) + +export default router diff --git a/web/src/style.css b/src/web/src/style.css similarity index 85% rename from web/src/style.css rename to src/web/src/style.css index 55900eb..c81c871 100644 --- a/web/src/style.css +++ b/src/web/src/style.css @@ -152,6 +152,39 @@ .pagination-button { @apply flex size-8 items-center justify-center rounded-md text-xs font-medium transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-40; } + .diagnostic-card { + @apply grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1 rounded-xl border bg-card p-4 shadow-sm; + } + .diagnostic-card svg { + @apply row-span-2 size-5 text-muted-foreground; + } + .diagnostic-card span { + @apply text-xs text-muted-foreground; + } + .diagnostic-card b { + @apply text-base font-semibold; + } + .chart-card { + @apply min-w-0 rounded-xl border bg-card p-4 shadow-sm; + } + .chart-card h2 { + @apply text-sm font-semibold; + } + .chart-card p { + @apply mt-1 text-xs text-muted-foreground; + } + .config-field { + @apply flex gap-3 border-b py-4 last:border-b-0; + } + .config-field b { + @apply block text-sm font-medium; + } + .config-field small { + @apply mt-1 block text-xs leading-relaxed text-muted-foreground; + } + .config-input { + @apply mt-2 h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus:border-foreground/25 focus:ring-3 focus:ring-ring/15; + } dl dt { @apply text-xs text-muted-foreground; } diff --git a/src/web/src/types/api.ts b/src/web/src/types/api.ts new file mode 100644 index 0000000..1a688de --- /dev/null +++ b/src/web/src/types/api.ts @@ -0,0 +1,303 @@ +export type AvailabilityStatus = 'unknown' | 'active' | 'possibly_stale' +export type HeatLevel = 'hot' | 'active' | 'normal' | 'cold' +export type SearchSort = + | 'relevance' + | 'latest' + | 'oldest' + | 'heat' + | 'size_desc' + | 'size_asc' + | 'discoveries' + +export interface Heat { + score: number + level: HeatLevel +} + +export interface AvailabilitySummary { + status: AvailabilityStatus + last_verified_at: number | null + reachable_peers: number +} + +export interface Availability extends AvailabilitySummary { + last_success_at: number | null + discovered_peers: number + consecutive_failures: number + next_check_at: number +} + +export interface SearchHit { + info_hash: string + name: string + total_size: number + file_count: number + first_seen: number + last_seen: number + seen_count: number + content_key: string + variant_count: number + score: number + heat: Heat + availability: AvailabilitySummary +} + +export interface SearchPage { + total: number + offset: number + limit: number + hits: SearchHit[] + sort: SearchSort +} + +export interface TorrentFile { + path: string + size: number +} + +export interface TorrentDetail { + info_hash: string + magnet_link: string + name: string + total_size: number + file_count: number + file_offset: number + file_limit: number + files: TorrentFile[] + piece_length: number + content_key: string + first_seen: number + last_seen: number + seen_count: number + heat: Heat + availability: Availability +} + +export interface TorrentVariant { + info_hash: string + magnet_link: string + name: string + total_size: number + file_count: number + first_seen: number + last_seen: number + seen_count: number + heat: Heat + availability: Availability +} + +export interface ContentVariants { + content_key: string + total: number + offset: number + limit: number + variants: TorrentVariant[] +} + +export interface ServiceStats { + http_active_requests: number + http_requests: number + http_client_errors: number + http_server_errors: number + http_latency_average_micros: number | null + http_latency_p95_millis: number | null + http_latency_max_millis: number + disk_state: 'normal' | 'draining' | 'read_only' + disk_available_bytes: number | null + disk_minimum_free_bytes: number + disk_resume_free_bytes: number + disk_active_writes: number + disk_probe_failed: boolean + disk_probe_failures: number + disk_transitions: number + disk_rejected_new_work: number + nodes: number + metadata_ok: number + metadata_failed: number + metadata_in_flight: number + metadata_filtered: number + persistence_inserted: number + persistence_updated: number + persistence_queue: number + indexed_documents: number + verification_queue: number + verification_succeeded: number + verification_failed: number +} + +export interface ProcessDiagnostics { + resident_memory_bytes: number | null + private_memory_bytes: number | null + cpu_time_millis: number | null + thread_count: number | null + handle_count: number | null +} + +export interface StorageDiagnostics { + block_cache_bytes: number | null + memtable_bytes: number | null + pending_compaction_bytes: number | null + live_sst_bytes: number | null + running_compactions: number | null + estimated_keys: number | null +} + +export interface SearchDiagnostics { + documents: number + writer_memory_budget_bytes: number + commits: number + commit_failures: number + last_commit_at: number | null + last_commit_duration_millis: number + last_commit_documents: number +} + +export interface RuntimeDiagnostics { + nodes: number + udp_tx_packets: number + metadata_in_flight: number + metadata_succeeded: number + metadata_failed: number + sample_queue_depth: number + sample_queue_capacity: number + persistence_queue_depth: number + indexed_documents: number + disk_available_bytes: number | null +} + +export interface DiagnosticSample { + captured_at: number + session_started_at: number + process: ProcessDiagnostics + storage: StorageDiagnostics + search: SearchDiagnostics + http: HttpDiagnostics + runtime: RuntimeDiagnostics +} + +export interface HttpDiagnostics { + active_requests: number + requests: number + client_errors: number + server_errors: number + latency_average_micros: number | null + latency_p95_millis: number | null + latency_max_millis: number +} + +export interface DiagnosticsStatus { + enabled: boolean + persisted_samples: number + dropped_samples: number + skipped_samples: number + write_failures: number + last_persisted_at: number | null + last_error: string | null +} + +export interface CurrentDiagnostics { + status: DiagnosticsStatus + sample: DiagnosticSample | null +} + +export interface DiagnosticHistory { + resolution: 'raw' | 'minute' + from: number + to: number + samples: DiagnosticSample[] +} + +export type NetworkMode = 'ipv4-only' | 'ipv6-only' | 'dual-stack' +export type LogRotation = 'minutely' | 'hourly' | 'daily' | 'never' + +export interface AppConfigDto { + data_dir: string + content_filter_file: string + persistence_queue_capacity: number + stats_interval_secs: number + run_duration_secs: number | null + index_batch_size: number + index_interval_millis: number + metadata_limits: { + max_metadata_bytes: number + max_files: number + max_name_bytes: number + max_path_bytes: number + max_path_depth: number + } + dht: { + port: number + netmode: NetworkMode + hash_queue_capacity: number + max_outbound_queries_per_second: number + outbound_query_burst: number + metadata_timeout_secs: number + metadata_queue_capacity: number + metadata_workers: number + metadata_connects_per_second: number + sample_queries_per_second: number + sample_max_in_flight: number + sample_new_node_percent: number + sample_candidate_queue_capacity: number + sample_fallback_to_iterative: boolean + peer_lookups_per_second: number + peer_lookup_max_active: number + find_node_queries_per_second: number + find_node_max_in_flight: number + new_destinations_per_minute: number + } + disk_guard: { + enabled: boolean + check_interval_secs: number + minimum_free_bytes: number + resume_free_bytes: number + } + backup: { + enabled: boolean + directory: string + interval_secs: number + retain_checkpoints: number + create_on_start: boolean + } + diagnostics: { + enabled: boolean + database: string + sample_interval_secs: number + raw_retention_hours: number + minute_retention_days: number + queue_capacity: number + } + logging: { + directory: string + file_enabled: boolean + console_enabled: boolean + rotation: LogRotation + retain_files: number + file_prefix: string + } + http: { + listen: string + web_dir: string + } + verification: { + enabled: boolean + queue_capacity: number + max_active: number + max_peer_attempts: number + lease_secs: number + poll_interval_millis: number + } +} + +export interface ConfigSnapshot { + revision: string + restart_required: boolean + source: string + command_line_overrides: string[] + config: AppConfigDto +} + +export interface ConfigUpdateRequest { + revision: string + config: AppConfigDto +} diff --git a/web/tsconfig.app.json b/src/web/tsconfig.app.json similarity index 100% rename from web/tsconfig.app.json rename to src/web/tsconfig.app.json diff --git a/web/tsconfig.json b/src/web/tsconfig.json similarity index 100% rename from web/tsconfig.json rename to src/web/tsconfig.json diff --git a/web/tsconfig.node.json b/src/web/tsconfig.node.json similarity index 100% rename from web/tsconfig.node.json rename to src/web/tsconfig.node.json diff --git a/web/vite.config.ts b/src/web/vite.config.ts similarity index 55% rename from web/vite.config.ts rename to src/web/vite.config.ts index af1947f..71d7dc0 100644 --- a/web/vite.config.ts +++ b/src/web/vite.config.ts @@ -3,6 +3,8 @@ import tailwindcss from '@tailwindcss/vite' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +const apiTarget = process.env.DHT_API_TARGET ?? 'http://127.0.0.1:8080' + // https://vite.dev/config/ export default defineConfig({ plugins: [vue(), tailwindcss()], @@ -13,12 +15,14 @@ export default defineConfig({ }, server: { proxy: { - '/health': 'http://127.0.0.1:8080', - '/ready': 'http://127.0.0.1:8080', - '/stats': 'http://127.0.0.1:8080', - '/search': 'http://127.0.0.1:8080', - '/contents': 'http://127.0.0.1:8080', - '/torrents': 'http://127.0.0.1:8080', + '/health': apiTarget, + '/ready': apiTarget, + '/stats': apiTarget, + '/diagnostics': apiTarget, + '/config': apiTarget, + '/search': apiTarget, + '/contents': apiTarget, + '/torrents': apiTarget, }, }, }) diff --git a/web/src/types/api.ts b/web/src/types/api.ts deleted file mode 100644 index 20d5105..0000000 --- a/web/src/types/api.ts +++ /dev/null @@ -1,119 +0,0 @@ -export type AvailabilityStatus = 'unknown' | 'active' | 'possibly_stale' -export type HeatLevel = 'hot' | 'active' | 'normal' | 'cold' -export type SearchSort = - | 'relevance' - | 'latest' - | 'oldest' - | 'heat' - | 'size_desc' - | 'size_asc' - | 'discoveries' - -export interface Heat { - score: number - level: HeatLevel -} - -export interface AvailabilitySummary { - status: AvailabilityStatus - last_verified_at: number | null - reachable_peers: number -} - -export interface Availability extends AvailabilitySummary { - last_success_at: number | null - discovered_peers: number - consecutive_failures: number - next_check_at: number -} - -export interface SearchHit { - info_hash: string - name: string - total_size: number - file_count: number - first_seen: number - last_seen: number - seen_count: number - content_key: string - variant_count: number - score: number - heat: Heat - availability: AvailabilitySummary -} - -export interface SearchPage { - total: number - offset: number - limit: number - hits: SearchHit[] - sort: SearchSort -} - -export interface TorrentFile { - path: string - size: number -} - -export interface TorrentDetail { - info_hash: string - magnet_link: string - name: string - total_size: number - file_count: number - file_offset: number - file_limit: number - files: TorrentFile[] - piece_length: number - content_key: string - first_seen: number - last_seen: number - seen_count: number - heat: Heat - availability: Availability -} - -export interface TorrentVariant { - info_hash: string - magnet_link: string - name: string - total_size: number - file_count: number - first_seen: number - last_seen: number - seen_count: number - heat: Heat - availability: Availability -} - -export interface ContentVariants { - content_key: string - total: number - offset: number - limit: number - variants: TorrentVariant[] -} - -export interface ServiceStats { - disk_state: 'normal' | 'draining' | 'read_only' - disk_available_bytes: number | null - disk_minimum_free_bytes: number - disk_resume_free_bytes: number - disk_active_writes: number - disk_probe_failed: boolean - disk_probe_failures: number - disk_transitions: number - disk_rejected_new_work: number - nodes: number - metadata_ok: number - metadata_failed: number - metadata_in_flight: number - metadata_filtered: number - persistence_inserted: number - persistence_updated: number - persistence_queue: number - indexed_documents: number - verification_queue: number - verification_succeeded: number - verification_failed: number -}