use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use hermes_core::segment::BpBudget;
use log::{debug, info, warn};
use tokio::sync::Semaphore;
use crate::registry::IndexRegistry;
#[derive(Debug, Clone)]
pub struct OptimizerConfig {
pub threads: usize,
pub scan_interval: Duration,
pub large_segment_docs: u32,
pub time_budget: Duration,
pub partial_min_partition_docs: usize,
pub unconverged_cooldown: Duration,
}
#[derive(Default)]
struct DeepeningGate {
state: Mutex<DeepeningGateState>,
}
#[derive(Default)]
struct DeepeningGateState {
in_flight: bool,
last_finished: Option<Instant>,
}
impl DeepeningGate {
fn try_acquire(self: &Arc<Self>, cooldown: Duration) -> Option<DeepeningPermit> {
let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
if state.in_flight
|| state
.last_finished
.is_some_and(|finished| finished.elapsed() < cooldown)
{
return None;
}
state.in_flight = true;
Some(DeepeningPermit {
gate: Arc::clone(self),
})
}
}
struct DeepeningPermit {
gate: Arc<DeepeningGate>,
}
impl Drop for DeepeningPermit {
fn drop(&mut self) {
let mut state = self.gate.state.lock().unwrap_or_else(|e| e.into_inner());
state.last_finished = Some(Instant::now());
state.in_flight = false;
}
}
pub fn spawn_optimizer(
registry: Arc<IndexRegistry>,
config: OptimizerConfig,
) -> Option<tokio::task::JoinHandle<()>> {
if config.threads == 0 {
return None;
}
info!(
"Starting background optimizer: {} threads, {:.0}s scan interval",
config.threads,
config.scan_interval.as_secs_f64(),
);
let semaphore = Arc::new(Semaphore::new(config.threads));
let bp_pool_threads = std::thread::available_parallelism()
.map(|c| c.get() / 2)
.unwrap_or(config.threads)
.max(config.threads);
let rayon_pool = Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(bp_pool_threads)
.thread_name(|idx| format!("optimizer-bp-{}", idx))
.build()
.expect("failed to create optimizer rayon pool"),
);
Some(tokio::spawn(async move {
optimizer_loop(registry, semaphore, rayon_pool, config).await;
}))
}
async fn optimizer_loop(
registry: Arc<IndexRegistry>,
semaphore: Arc<Semaphore>,
rayon_pool: Arc<rayon::ThreadPool>,
config: OptimizerConfig,
) {
tokio::time::sleep(Duration::from_secs(5)).await;
let deepening_gate = Arc::new(DeepeningGate::default());
loop {
if let Err(e) =
scan_and_optimize(®istry, &semaphore, &rayon_pool, &config, &deepening_gate).await
{
warn!("[optimizer] scan failed: {}", e);
}
tokio::time::sleep(config.scan_interval).await;
}
}
async fn scan_and_optimize(
registry: &IndexRegistry,
semaphore: &Arc<Semaphore>,
rayon_pool: &Arc<rayon::ThreadPool>,
config: &OptimizerConfig,
deepening_gate: &Arc<DeepeningGate>,
) -> Result<(), tonic::Status> {
let index_names = registry.list_indexes().await?;
for name in index_names {
let index = match registry.get_or_open_index(&name).await {
Ok(idx) => idx,
Err(e) => {
debug!("[optimizer] cannot open index '{}': {}", name, e);
continue;
}
};
let writer = match registry.get_writer(&name).await {
Ok(w) => w,
Err(e) => {
debug!("[optimizer] cannot get writer for '{}': {}", name, e);
continue;
}
};
let segment_manager = {
let w = writer.read().await;
Arc::clone(w.segment_manager())
};
match segment_manager.cleanup_orphan_segments().await {
Ok(0) => {}
Ok(n) => warn!(
"[optimizer] swept {} unowned orphan segment(s) in '{}'",
n, name
),
Err(e) => debug!("[optimizer] orphan sweep failed for '{}': {}", name, e),
}
if !index.schema().has_reorder_fields() {
continue;
}
let fresh = segment_manager.unreordered_segments().await;
let mut candidates: Vec<(String, u32, Option<DeepeningPermit>)> = fresh
.into_iter()
.map(|(id, docs)| (id, docs, None))
.collect();
let unconverged = segment_manager.unconverged_segments().await;
if let Some((id, docs)) = unconverged.into_iter().next()
&& let Some(permit) = deepening_gate.try_acquire(config.unconverged_cooldown)
{
debug!(
"[optimizer] index '{}': deepening unconverged segment {} ({} docs)",
name, id, docs,
);
candidates.push((id, docs, Some(permit)));
}
if candidates.is_empty() {
continue;
}
debug!(
"[optimizer] index '{}': {} reorder candidate(s)",
name,
candidates.len()
);
for (seg_id, num_docs, deepening_permit) in candidates {
let is_deepening = deepening_permit.is_some();
let permit = match semaphore.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => {
return Ok(());
}
};
let sm = Arc::clone(&segment_manager);
let idx_name = name.clone();
let sid = seg_id.clone();
let pool = Arc::clone(rayon_pool);
let budget = if is_deepening {
info!(
"[optimizer] deepening segment {} ({} docs): full depth, time budget {:.0}s",
sid,
num_docs,
config.time_budget.as_secs_f64(),
);
BpBudget {
min_partition_docs: None,
time_budget: Some(config.time_budget),
}
} else if num_docs >= config.large_segment_docs {
info!(
"[optimizer] segment {} ({} docs) exceeds {} docs — budgeted BP pass \
(min_partition={} docs, time budget {:.0}s)",
sid,
num_docs,
config.large_segment_docs,
config.partial_min_partition_docs,
config.time_budget.as_secs_f64(),
);
BpBudget {
min_partition_docs: Some(config.partial_min_partition_docs),
time_budget: Some(config.time_budget),
}
} else {
BpBudget::full()
};
tokio::spawn(async move {
let _permit = permit;
let _deepening_permit = deepening_permit;
let start = std::time::Instant::now();
match sm.reorder_single_segment(&sid, Some(pool), budget).await {
Ok(true) => {
info!(
"[optimizer] reordered segment {} in index '{}' ({:.1}s)",
sid,
idx_name,
start.elapsed().as_secs_f64(),
);
}
Ok(false) => {
debug!(
"[optimizer] segment {} in index '{}' skipped (in merge)",
sid, idx_name
);
}
Err(e) => {
warn!(
"[optimizer] failed to reorder segment {} in index '{}': {}",
sid, idx_name, e
);
}
}
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deepening_gate_blocks_overlap_and_cools_down_from_completion() {
let gate = Arc::new(DeepeningGate::default());
let first = gate
.try_acquire(Duration::from_secs(60))
.expect("first pass should start");
assert!(
gate.try_acquire(Duration::ZERO).is_none(),
"a second deepening pass must not overlap"
);
drop(first);
assert!(
gate.try_acquire(Duration::from_secs(60)).is_none(),
"cooldown must begin when the pass completes"
);
assert!(
gate.try_acquire(Duration::ZERO).is_some(),
"the gate should reopen after its cooldown"
);
}
}