mod converters;
mod error;
mod index_service;
mod optimizer;
mod registry;
mod search_service;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use clap::Parser;
use log::{info, warn};
use tonic::{codec::CompressionEncoding, transport::Server};
use hermes_core::IndexConfig;
pub mod proto {
tonic::include_proto!("hermes");
}
use proto::index_service_server::IndexServiceServer;
use proto::search_service_server::SearchServiceServer;
#[derive(Parser, Debug)]
#[command(name = "hermes-server")]
#[command(about = "A high-performance async search server")]
struct Args {
#[arg(short, long, default_value = "0.0.0.0:50051")]
addr: String,
#[arg(short, long, default_value = "./data")]
data_dir: PathBuf,
#[arg(short, long)]
cache_dir: Option<PathBuf>,
#[arg(long, default_value = "16384")]
max_indexing_memory_mb: usize,
#[arg(long)]
indexing_threads: Option<usize>,
#[arg(long, default_value = "1000")]
reload_interval_ms: u64,
#[arg(long)]
worker_threads: Option<usize>,
#[arg(long)]
doctor: bool,
#[arg(long, default_value = "0")]
optimizer_threads: usize,
#[arg(long, default_value = "2")]
optimizer_concurrent_passes: usize,
#[arg(long, default_value = "60")]
optimizer_scan_interval_secs: u64,
#[arg(long, default_value = "5000000")]
optimizer_large_segment_docs: u32,
#[arg(long, default_value = "600")]
optimizer_time_budget_secs: u64,
#[arg(long, default_value = "4096")]
optimizer_partial_min_partition_docs: usize,
#[arg(long, default_value = "600")]
optimizer_unconverged_cooldown_secs: u64,
#[arg(long, default_value = "600")]
merge_bp_budget_secs: u64,
#[arg(long, default_value = "24576")]
bp_memory_budget_mb: usize,
#[arg(long, default_value = "0.0.0.0:9184")]
metrics_addr: String,
}
fn main() -> Result<()> {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let bt = std::backtrace::Backtrace::force_capture();
eprintln!("=== PANIC ===\n{info}\n{bt}");
default_hook(info);
}));
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("hermes_server=info"),
)
.init();
let args = Args::parse();
let worker_threads = args
.worker_threads
.unwrap_or_else(|| num_cpus::get().min(16));
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(worker_threads)
.thread_name("hermes-worker")
.thread_stack_size(4 * 1024 * 1024)
.enable_all()
.build()?;
runtime.block_on(async_main(args, worker_threads))
}
async fn async_main(args: Args, worker_threads: usize) -> Result<()> {
if let Some(cache_dir) = &args.cache_dir {
std::fs::create_dir_all(cache_dir)?;
unsafe { std::env::set_var("HF_HOME", cache_dir) };
info!("HuggingFace cache directory: {:?}", cache_dir);
}
if args.metrics_addr != "off" {
let metrics_addr: SocketAddr = args.metrics_addr.parse().map_err(|e| {
anyhow::anyhow!("invalid --metrics-addr '{}': {}", args.metrics_addr, e)
})?;
metrics_exporter_prometheus::PrometheusBuilder::new()
.with_http_listener(metrics_addr)
.install()
.map_err(|e| {
anyhow::anyhow!(
"failed to start metrics exporter on {}: {}",
metrics_addr,
e
)
})?;
info!("Prometheus metrics on http://{}/metrics", metrics_addr);
} else {
warn!("Prometheus metrics exporter disabled (--metrics-addr off)");
}
std::fs::create_dir_all(&args.data_dir)?;
let addr: SocketAddr = args.addr.parse()?;
let num_indexing_threads = args
.indexing_threads
.unwrap_or_else(|| (num_cpus::get() / 4).max(1));
let max_indexing_memory_bytes = args
.max_indexing_memory_mb
.checked_mul(1024 * 1024)
.ok_or_else(|| anyhow::anyhow!("--max-indexing-memory-mb is too large"))?;
let bp_memory_budget_bytes = args
.bp_memory_budget_mb
.checked_mul(1024 * 1024)
.ok_or_else(|| anyhow::anyhow!("--bp-memory-budget-mb is too large"))?;
let concurrent_reorder_passes = args.optimizer_concurrent_passes.max(1);
if args.optimizer_concurrent_passes == 0 {
warn!("--optimizer-concurrent-passes=0 is invalid; using 1");
}
let background_reorder_pool = if args.optimizer_threads > 0 {
Some(Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(args.optimizer_threads)
.thread_name(|idx| format!("hermes-bp-{}", idx))
.build()
.map_err(|e| anyhow::anyhow!("failed to create BP thread pool: {}", e))?,
))
} else {
None
};
let config = IndexConfig {
max_indexing_memory_bytes,
num_indexing_threads,
reload_interval_ms: args.reload_interval_ms,
merge_policy: Box::new(hermes_core::merge::TieredMergePolicy::large_scale()),
merge_bp_time_budget: (args.merge_bp_budget_secs > 0)
.then(|| Duration::from_secs(args.merge_bp_budget_secs)),
bp_memory_budget_bytes,
background_reorder_permits: Arc::new(tokio::sync::Semaphore::new(
concurrent_reorder_passes,
)),
background_reorder_pool,
..Default::default()
};
let registry = Arc::new(registry::IndexRegistry::new(args.data_dir.clone(), config));
registry.cleanup_incomplete_deletes();
if args.doctor {
registry.doctor_all_indexes().await;
}
let search_service = search_service::SearchServiceImpl {
registry: Arc::clone(®istry),
};
let index_service = index_service::IndexServiceImpl {
registry: Arc::clone(®istry),
};
let _optimizer_handle = optimizer::spawn_optimizer(
Arc::clone(®istry),
optimizer::OptimizerConfig {
threads: args.optimizer_threads,
concurrent_passes: concurrent_reorder_passes,
scan_interval: Duration::from_secs(args.optimizer_scan_interval_secs),
large_segment_docs: args.optimizer_large_segment_docs,
time_budget: Duration::from_secs(args.optimizer_time_budget_secs),
partial_min_partition_docs: args.optimizer_partial_min_partition_docs,
unconverged_cooldown: Duration::from_secs(args.optimizer_unconverged_cooldown_secs),
},
);
info!("Hermes server v{}", env!("CARGO_PKG_VERSION"));
info!("Starting Hermes server on {}", addr);
info!("Data directory: {:?}", args.data_dir);
info!("Max indexing memory: {} MB", args.max_indexing_memory_mb);
info!("Indexing threads: {}", num_indexing_threads);
info!("Worker threads: {}", worker_threads);
info!("Reload interval: {} ms", args.reload_interval_ms);
if args.optimizer_threads > 0 {
info!(
"Optimizer: {} shared BP threads, {} concurrent pass(es), {}s scan interval",
args.optimizer_threads, concurrent_reorder_passes, args.optimizer_scan_interval_secs,
);
}
const SEARCH_MAX_DECODE: usize = 4 * 1024 * 1024; const SEARCH_MAX_ENCODE: usize = 64 * 1024 * 1024; const INDEX_MAX_DECODE: usize = 256 * 1024 * 1024; const INDEX_MAX_ENCODE: usize = 64 * 1024 * 1024;
Server::builder()
.tcp_keepalive(Some(Duration::from_secs(60)))
.http2_keepalive_interval(Some(Duration::from_secs(30)))
.http2_keepalive_timeout(Some(Duration::from_secs(10)))
.http2_adaptive_window(Some(true))
.initial_connection_window_size(Some(4 * 1024 * 1024))
.initial_stream_window_size(Some(2 * 1024 * 1024))
.max_concurrent_streams(Some(256))
.concurrency_limit_per_connection(128)
.add_service(
SearchServiceServer::new(search_service)
.max_decoding_message_size(SEARCH_MAX_DECODE)
.max_encoding_message_size(SEARCH_MAX_ENCODE)
.accept_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Zstd),
)
.add_service(
IndexServiceServer::new(index_service)
.max_decoding_message_size(INDEX_MAX_DECODE)
.max_encoding_message_size(INDEX_MAX_ENCODE)
.accept_compressed(CompressionEncoding::Gzip)
.accept_compressed(CompressionEncoding::Zstd)
.send_compressed(CompressionEncoding::Zstd),
)
.serve_with_shutdown(addr, shutdown_signal())
.await?;
info!("Hermes server shut down gracefully");
Ok(())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install ctrl+c handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {
warn!("Received ctrl+c, starting graceful shutdown...");
}
_ = terminate => {
warn!("Received SIGTERM, starting graceful shutdown...");
}
}
}