htknow/src/api/system.rs
Defeng fe8f0a875a
Some checks failed
CI / Frontend (Vite) (push) Successful in 24s
CI / Docker Release Tar (push) Has been skipped
CI / Backend (Rust) (push) Failing after 10m15s
Initial project import
2026-07-24 10:28:31 +08:00

519 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use axum::{Extension, Json, extract::State, response::Response};
use chrono::Utc;
use serde::Serialize;
use sqlx::SqlitePool;
use utoipa::ToSchema;
use crate::{
AuthUser, api::{
common, error::{ApiError, ApiResult}
}, search::{SearchEngine, tantivy_engine::ForceMergeStats}
};
/// 进程内存占用
#[derive(Debug, Serialize, ToSchema)]
pub struct MemoryUsage {
/// 进程 ID
pub pid: u32,
/// 常驻内存RSS单位字节
pub rss_bytes: u64,
/// 虚拟内存,单位:字节
pub virtual_bytes: u64,
}
/// 堆分析状态
#[derive(Debug, Serialize, ToSchema)]
pub struct HeapProfileStatus {
/// jemalloc 版本
pub jemalloc_version: Option<String>,
/// 是否启用 heap profilingopt.prof
pub prof_enabled: Option<bool>,
/// 是否开启采样prof.active
pub prof_active: Option<bool>,
/// jemalloc 构建时默认配置
pub malloc_conf: Option<String>,
/// 运行时环境变量 MALLOC_CONF
pub env_malloc_conf: Option<String>,
/// 状态读取失败的提示
pub warnings: Vec<String>,
}
/// LanceDB compact 统计信息
#[derive(Debug, Serialize, ToSchema)]
pub struct LanceDbCompactStats {
/// 删除行数(本次 compact 清理)
pub deleted_rows: u64,
/// compact 前被标记删除的行数
pub deleted_rows_before: u64,
/// compact 后被标记删除的行数
pub deleted_rows_after: u64,
/// compact 前总行数
pub total_rows_before: u64,
/// compact 后总行数
pub total_rows_after: u64,
/// compact 前磁盘大小(字节)
pub size_before_bytes: u64,
/// compact 后磁盘大小(字节)
pub size_after_bytes: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct IndexRebuildStatus {
/// 任务 ID无任务时为 null
pub job_id: Option<i64>,
/// 任务状态idle/running/completed/failed
pub status: String,
/// 当前阶段描述
pub phase: String,
/// 总文档数
pub total_docs: i64,
/// 已处理文档数
pub processed_docs: i64,
/// 进度百分比0-100
pub progress_pct: f32,
/// 已运行秒数
pub elapsed_secs: Option<i64>,
/// 预计剩余秒数ETA
pub eta_secs: Option<i64>,
/// 任务开始时间(秒级时间戳)
pub started_at: Option<i64>,
/// 最近更新时间(秒级时间戳)
pub updated_at: Option<i64>,
/// 任务结束时间(秒级时间戳)
pub finished_at: Option<i64>,
/// 失败时错误信息
pub error: Option<String>,
}
/// 单个 Tantivy 索引 force merge 统计信息
#[derive(Debug, Serialize, ToSchema)]
pub struct TantivyForceMergeIndexStats {
/// 索引名称index/full_index
pub index: String,
/// merge 前 segment 数
pub before_segments: usize,
/// merge 后 segment 数
pub after_segments: usize,
/// merge 前存活文档数
pub before_docs: u64,
/// merge 后存活文档数
pub after_docs: u64,
/// merge 前已删除文档数
pub before_deleted_docs: u64,
/// merge 后已删除文档数
pub after_deleted_docs: u64,
/// GC 删除的不再被索引引用的文件数
pub gc_deleted_files: usize,
/// GC 删除失败的文件数
pub gc_failed_files: usize,
/// 是否因为 segment 少于 2 个而跳过
pub skipped: bool,
/// 单个索引耗时(毫秒)
pub duration_ms: u128,
}
/// Tantivy force merge 响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TantivyForceMergeResponse {
/// 普通切片索引统计
pub index: TantivyForceMergeIndexStats,
/// 全文索引统计
pub full_index: TantivyForceMergeIndexStats,
/// 总耗时(毫秒)
pub total_duration_ms: u128,
}
#[derive(Debug, sqlx::FromRow)]
struct IndexRebuildStatusRow {
id: i64,
status: String,
phase: String,
total_docs: i64,
processed_docs: i64,
started_at: Option<i64>,
updated_at: Option<i64>,
finished_at: Option<i64>,
error: Option<String>,
}
/// 生成当前进程堆内存快照jemalloc heap profile
#[utoipa::path(
get,
path = "/api/v1/knowledge/system/heap",
operation_id = "system_heap_profile",
tag = "system",
responses(
(status = 200, description = "成功返回堆分析快照", body = Vec<u8>, content_type = "application/octet-stream"),
(status = 400, description = "未启用堆分析"),
(status = 500, description = "生成堆分析快照失败")
)
)]
pub async fn heap_profile() -> ApiResult<Response> {
heap_profile_impl().await
}
/// 生成堆内存分析 PDF 报告(使用 jeprof
#[utoipa::path(
get,
path = "/api/v1/knowledge/system/heap/pdf",
operation_id = "system_heap_profile_pdf",
tag = "system",
responses(
(status = 200, description = "成功返回堆分析 PDF 报告", body = Vec<u8>, content_type = "application/pdf"),
(status = 400, description = "未启用堆分析或 jeprof 不可用"),
(status = 500, description = "生成堆分析 PDF 失败")
)
)]
pub async fn heap_profile_pdf() -> ApiResult<Response> {
heap_profile_pdf_impl().await
}
/// 主动触发 LanceDB compact
#[utoipa::path(
post,
path = "/api/v1/knowledge/system/lancedb/compact",
operation_id = "system_lancedb_compact",
tag = "system",
responses(
(status = 200, description = "LanceDB compact 成功", body = LanceDbCompactStats),
(status = 500, description = "LanceDB compact 失败")
),
security(
("x-user-id" = []),
("x-role" = [])
)
)]
pub async fn lancedb_compact(
Extension(search_engine): Extension<SearchEngine>,
) -> ApiResult<Json<LanceDbCompactStats>> {
let stats = search_engine
.compact_lancedb()
.await
.map_err(|e| ApiError::internal(format!("LanceDB compact failed: {}", e)))?;
Ok(Json(LanceDbCompactStats {
deleted_rows: stats.deleted_rows,
deleted_rows_before: stats.deleted_rows_before,
deleted_rows_after: stats.deleted_rows_after,
total_rows_before: stats.total_rows_before,
total_rows_after: stats.total_rows_after,
size_before_bytes: stats.size_before_bytes,
size_after_bytes: stats.size_after_bytes,
}))
}
/// 强制合并 Tantivy segment减少索引碎片
#[utoipa::path(
post,
path = "/api/v1/knowledge/system/index/force-merge",
operation_id = "system_index_force_merge",
tag = "system",
responses(
(status = 200, description = "Tantivy force merge 成功", body = TantivyForceMergeResponse),
(status = 400, description = "权限不足"),
(status = 500, description = "Tantivy force merge 失败")
),
security(
("x-user-id" = []),
("x-role" = [])
)
)]
pub async fn index_force_merge(
Extension(search_engine): Extension<SearchEngine>, Extension(auth_user): Extension<AuthUser>,
) -> ApiResult<Json<TantivyForceMergeResponse>> {
common::ensure_admin(&auth_user)?;
let start = std::time::Instant::now();
let (index_stats, full_index_stats) = search_engine
.force_merge_tantivy_indexes()
.await
.map_err(|e| ApiError::internal(format!("Tantivy force merge failed: {}", e)))?;
Ok(Json(TantivyForceMergeResponse {
index: force_merge_index_stats("index", index_stats),
full_index: force_merge_index_stats("full_index", full_index_stats),
total_duration_ms: start.elapsed().as_millis(),
}))
}
/// 查询索引重建进度与 ETA
#[utoipa::path(
get,
path = "/api/v1/knowledge/system/index/rebuild/status",
operation_id = "system_index_rebuild_status",
tag = "system",
responses(
(status = 200, description = "成功返回索引重建状态", body = IndexRebuildStatus)
),
security(
("x-user-id" = []),
("x-role" = [])
)
)]
pub async fn index_rebuild_status(
State(pool): State<SqlitePool>, Extension(auth_user): Extension<AuthUser>,
) -> ApiResult<Json<IndexRebuildStatus>> {
common::ensure_admin(&auth_user)?;
let row: Option<IndexRebuildStatusRow> = sqlx::query_as(
"SELECT id, status, phase, total_docs, processed_docs, started_at, updated_at, finished_at, error
FROM index_rebuild_jobs
ORDER BY CASE WHEN status = 'running' THEN 0 ELSE 1 END, id DESC
LIMIT 1",
)
.fetch_optional(&pool)
.await?;
let Some(row) = row else {
return Ok(Json(IndexRebuildStatus {
job_id: None,
status: "idle".to_string(),
phase: String::new(),
total_docs: 0,
processed_docs: 0,
progress_pct: 0.0,
elapsed_secs: None,
eta_secs: None,
started_at: None,
updated_at: None,
finished_at: None,
error: None,
}));
};
let total_docs = row.total_docs.max(0);
let processed_docs = row.processed_docs.clamp(0, total_docs.max(0));
let progress_pct = if total_docs > 0 {
((processed_docs as f64 / total_docs as f64) * 100.0).clamp(0.0, 100.0) as f32
} else if row.status == "completed" {
100.0
} else {
0.0
};
let now = Utc::now().timestamp();
let elapsed_secs = match row.started_at {
Some(start) => match row.status.as_str() {
"running" => Some((now - start).max(0)),
_ => row.finished_at.map(|finish| (finish - start).max(0)).or(Some((now - start).max(0))),
},
None => None,
};
let eta_secs = if row.status == "running" && total_docs > processed_docs {
match elapsed_secs {
Some(elapsed) if elapsed > 0 && processed_docs > 0 => {
let speed = processed_docs as f64 / elapsed as f64;
if speed > 0.0 {
let remaining = (total_docs - processed_docs) as f64;
Some((remaining / speed).ceil() as i64)
} else {
None
}
}
_ => None,
}
} else if row.status == "completed" {
Some(0)
} else {
None
};
Ok(Json(IndexRebuildStatus {
job_id: Some(row.id),
status: row.status,
phase: row.phase,
total_docs,
processed_docs,
progress_pct,
elapsed_secs,
eta_secs,
started_at: row.started_at,
updated_at: row.updated_at,
finished_at: row.finished_at,
error: row.error,
}))
}
fn force_merge_index_stats(index: &str, stats: ForceMergeStats) -> TantivyForceMergeIndexStats {
TantivyForceMergeIndexStats {
index: index.to_string(),
before_segments: stats.before_segments,
after_segments: stats.after_segments,
before_docs: stats.before_docs,
after_docs: stats.after_docs,
before_deleted_docs: stats.before_deleted_docs,
after_deleted_docs: stats.after_deleted_docs,
gc_deleted_files: stats.gc_deleted_files,
gc_failed_files: stats.gc_failed_files,
skipped: stats.skipped,
duration_ms: stats.duration_ms,
}
}
#[cfg(feature = "profiling")]
async fn heap_profile_impl() -> ApiResult<Response> {
use std::{
ffi::CString, io::Seek, time::{SystemTime, UNIX_EPOCH}
};
use axum::{
body::Body, http::{HeaderValue, header}
};
use tempfile::NamedTempFile;
use tikv_jemalloc_ctl::raw;
use tokio_util::io::ReaderStream;
let pid = std::process::id();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| ApiError::internal(format!("SystemTime error: {}", e)))?
.as_secs();
let filename = format!("htknow.heap.{}.{}.heap", pid, ts);
// 将堆 dump 写入临时文件后立即删除路径,只保留 fd 用于流式返回。
let temp = NamedTempFile::new().map_err(|e| ApiError::internal(format!("create temp file failed: {}", e)))?;
let path = temp.path().to_path_buf();
let path_str = path.to_string_lossy().into_owned();
let c_path =
CString::new(path_str.clone()).map_err(|_| ApiError::internal("Failed to create C string for path"))?;
let ptr = c_path.as_ptr();
unsafe {
raw::write(b"prof.dump\0", ptr).map_err(|e| ApiError::internal(format!("jemalloc heap dump failed: {}", e)))?;
}
let mut std_file = temp.into_file();
std_file
.seek(std::io::SeekFrom::Start(0))
.map_err(|e| ApiError::internal(format!("seek temp file failed: {}", e)))?;
let file = tokio::fs::File::from_std(std_file);
let len = file.metadata().await.map_err(|e| ApiError::internal(format!("metadata failed: {}", e)))?.len();
let stream = ReaderStream::new(file);
let mut response = Response::new(Body::from_stream(stream));
let headers = response.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream"));
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&len.to_string())
.map_err(|e| ApiError::internal(format!("Invalid header value: {}", e)))?,
);
let disposition = format!("attachment; filename=\"{}\"", filename);
headers.insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&disposition).map_err(|e| ApiError::internal(format!("Invalid header value: {}", e)))?,
);
Ok(response)
}
#[cfg(not(feature = "profiling"))]
async fn heap_profile_impl() -> ApiResult<Response> {
Err(ApiError::BadRequest("Heap profiling is only available with 'profiling' feature enabled.".to_string()))
}
#[cfg(feature = "profiling")]
async fn heap_profile_pdf_impl() -> ApiResult<Response> {
use std::{
ffi::CString, process::Stdio, time::{SystemTime, UNIX_EPOCH}
};
use axum::{
body::Body, http::{HeaderValue, header}
};
use tempfile::NamedTempFile;
use tikv_jemalloc_ctl::raw;
use tokio::{io::AsyncSeekExt, process::Command};
use tokio_util::io::ReaderStream;
let pid = std::process::id();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| ApiError::internal(format!("SystemTime error: {}", e)))?
.as_secs();
// 生成 heap dump 文件(路径需要保持到 jeprof 执行完毕)
let heap_temp = NamedTempFile::new().map_err(|e| ApiError::internal(format!("create heap temp failed: {}", e)))?;
let heap_path = heap_temp.path().to_path_buf();
let heap_path_str = heap_path.to_string_lossy().into_owned();
let c_path = CString::new(heap_path_str.clone())
.map_err(|_| ApiError::internal("Failed to create C string for heap path"))?;
log::info!("Generating heap dump: {}", heap_path_str);
let ptr = c_path.as_ptr();
unsafe {
raw::write(b"prof.dump\0", ptr).map_err(|e| ApiError::internal(format!("jemalloc heap dump failed: {}", e)))?;
}
// 生成 PDF 文件(用 NamedTempFile 承接 jeprof 标准输出)
let pdf_temp = NamedTempFile::new().map_err(|e| ApiError::internal(format!("create pdf temp failed: {}", e)))?;
let pdf_std = pdf_temp.into_file();
let mut pdf_file = tokio::fs::File::from_std(pdf_std);
// 获取当前二进制文件路径
let binary_path =
std::env::current_exe().map_err(|e| ApiError::internal(format!("Failed to get binary path: {}", e)))?;
let binary_path_str = binary_path.to_string_lossy();
log::info!("Running jeprof: binary={}, heap={}, output=pdf_stream", binary_path_str, heap_path_str);
let mut child = Command::new("jeprof")
.arg("--show_bytes")
.arg("--pdf")
.arg(binary_path_str.as_ref())
.arg(&heap_path_str)
.stdout(Stdio::piped())
.spawn()
.map_err(|e| ApiError::internal(format!("Failed to execute jeprof (make sure jeprof is installed): {}", e)))?;
let mut stdout = child.stdout.take().ok_or_else(|| ApiError::internal("jeprof stdout unavailable"))?;
tokio::io::copy(&mut stdout, &mut pdf_file)
.await
.map_err(|e| ApiError::internal(format!("Failed to copy jeprof output: {}", e)))?;
let status = child.wait().await.map_err(|e| ApiError::internal(format!("jeprof process wait failed: {}", e)))?;
// jeprof 已完成heap dump 临时文件可以清理
drop(heap_temp);
if !status.success() {
let code = status.code().unwrap_or(-1);
log::error!("jeprof failed with exit code: {}", code);
return Err(ApiError::internal(format!("jeprof execution failed with exit code: {}", code)));
}
pdf_file
.seek(std::io::SeekFrom::Start(0))
.await
.map_err(|e| ApiError::internal(format!("seek pdf temp failed: {}", e)))?;
let len = pdf_file.metadata().await.map_err(|e| ApiError::internal(format!("pdf metadata failed: {}", e)))?.len();
if len == 0 {
return Err(ApiError::internal("jeprof generated empty PDF"));
}
let pdf_filename = format!("htknow.heap.{}.{}.pdf", pid, ts);
log::info!("Successfully generated heap profile PDF: {} bytes", len);
let stream = ReaderStream::new(pdf_file);
let mut response = Response::new(Body::from_stream(stream));
let headers = response.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/pdf"));
headers.insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&len.to_string())
.map_err(|e| ApiError::internal(format!("Invalid header value: {}", e)))?,
);
let disposition = format!("attachment; filename=\"{}\"", pdf_filename);
headers.insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&disposition).map_err(|e| ApiError::internal(format!("Invalid header value: {}", e)))?,
);
Ok(response)
}
#[cfg(not(feature = "profiling"))]
async fn heap_profile_pdf_impl() -> ApiResult<Response> {
Err(ApiError::BadRequest("Heap profiling is only available with 'profiling' feature enabled.".to_string()))
}