use std::collections::HashSet;
use std::sync::Arc;
use arc_swap::ArcSwapOption;
use tokio::sync::Mutex as AsyncMutex;
use tokio::task::JoinHandle;
use crate::directories::DirectoryWriter;
use crate::error::{Error, Result};
use crate::index::IndexMetadata;
use crate::segment::{SegmentId, SegmentSnapshot, SegmentTracker, TrainedVectorStructures};
#[cfg(feature = "native")]
use crate::segment::{SegmentMerger, SegmentReader};
use super::{MergePolicy, SegmentInfo};
struct MergeInventory {
inner: parking_lot::Mutex<HashSet<String>>,
}
impl MergeInventory {
fn new() -> Self {
Self {
inner: parking_lot::Mutex::new(HashSet::new()),
}
}
fn try_register(self: &Arc<Self>, segment_ids: Vec<String>) -> Option<MergeGuard> {
let mut inner = self.inner.lock();
for id in &segment_ids {
if inner.contains(id) {
log::debug!(
"[merge_inventory] rejected: {} overlaps with active merge ({} active IDs)",
id,
inner.len()
);
return None;
}
}
log::debug!(
"[merge_inventory] registered {} IDs (total active: {})",
segment_ids.len(),
inner.len() + segment_ids.len()
);
for id in &segment_ids {
inner.insert(id.clone());
}
Some(MergeGuard {
inventory: Arc::clone(self),
segment_ids,
})
}
fn snapshot(&self) -> HashSet<String> {
self.inner.lock().clone()
}
fn contains(&self, segment_id: &str) -> bool {
self.inner.lock().contains(segment_id)
}
}
struct MergeGuard {
inventory: Arc<MergeInventory>,
segment_ids: Vec<String>,
}
impl Drop for MergeGuard {
fn drop(&mut self) {
let mut inner = self.inventory.inner.lock();
for id in &self.segment_ids {
inner.remove(id);
}
}
}
struct OutputCleanupGuard {
segment_id: SegmentId,
cleanup: Option<Arc<dyn Fn(SegmentId) + Send + Sync>>,
}
impl OutputCleanupGuard {
fn new(segment_id: SegmentId, cleanup: Arc<dyn Fn(SegmentId) + Send + Sync>) -> Self {
Self {
segment_id,
cleanup: Some(cleanup),
}
}
fn disarm(&mut self) {
self.cleanup = None;
}
}
impl Drop for OutputCleanupGuard {
fn drop(&mut self) {
if let Some(cleanup) = self.cleanup.take() {
cleanup(self.segment_id);
}
}
}
struct ManagerState {
metadata: IndexMetadata,
merge_policy: Box<dyn MergePolicy>,
}
pub struct SegmentManager<D: DirectoryWriter + 'static> {
state: AsyncMutex<ManagerState>,
merge_inventory: Arc<MergeInventory>,
merge_handles: AsyncMutex<Vec<JoinHandle<()>>>,
trained: ArcSwapOption<TrainedVectorStructures>,
tracker: Arc<SegmentTracker>,
delete_fn: Arc<dyn Fn(Vec<SegmentId>) + Send + Sync>,
directory: Arc<D>,
schema: Arc<crate::dsl::Schema>,
term_cache_blocks: usize,
max_concurrent_merges: usize,
reorder_on_merge: bool,
merge_bp_time_budget: Option<std::time::Duration>,
bp_memory_budget_bytes: usize,
bg_cpu_pool: std::sync::OnceLock<Arc<rayon::ThreadPool>>,
}
impl<D: DirectoryWriter + 'static> SegmentManager<D> {
#[allow(clippy::too_many_arguments)]
pub fn new(
directory: Arc<D>,
schema: Arc<crate::dsl::Schema>,
metadata: IndexMetadata,
merge_policy: Box<dyn MergePolicy>,
term_cache_blocks: usize,
max_concurrent_merges: usize,
merge_bp_time_budget: Option<std::time::Duration>,
bp_memory_budget_bytes: usize,
) -> Self {
let reorder_on_merge = schema.reorder_on_merge();
if reorder_on_merge {
log::info!("[merge] reorder-on-merge enabled by index schema");
}
let tracker = Arc::new(SegmentTracker::new());
for seg_id in metadata.segment_metas.keys() {
tracker.register(seg_id);
}
let delete_fn: Arc<dyn Fn(Vec<SegmentId>) + Send + Sync> = {
let dir = Arc::clone(&directory);
let tracker = Arc::clone(&tracker);
Arc::new(move |segment_ids| {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
tracker.complete_deletion(&segment_ids);
return;
};
let dir = Arc::clone(&dir);
let tracker = Arc::clone(&tracker);
handle.spawn(async move {
for &segment_id in &segment_ids {
log::info!(
"[segment_cleanup] deleting deferred segment {}",
segment_id.0
);
let _ = crate::segment::delete_segment(dir.as_ref(), segment_id).await;
}
tracker.complete_deletion(&segment_ids);
});
})
};
Self {
state: AsyncMutex::new(ManagerState {
metadata,
merge_policy,
}),
merge_inventory: Arc::new(MergeInventory::new()),
merge_handles: AsyncMutex::new(Vec::new()),
trained: ArcSwapOption::new(None),
tracker,
delete_fn,
directory,
schema,
term_cache_blocks,
max_concurrent_merges: max_concurrent_merges.max(1),
reorder_on_merge,
merge_bp_time_budget,
bp_memory_budget_bytes,
bg_cpu_pool: std::sync::OnceLock::new(),
}
}
pub fn background_cpu_pool(&self) -> Arc<rayon::ThreadPool> {
Arc::clone(self.bg_cpu_pool.get_or_init(|| {
let threads = (num_cpus::get() / 2).max(1);
log::info!("[merge] background CPU pool: {} thread(s)", threads);
Arc::new(
rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.thread_name(|i| format!("hermes-bg-cpu-{}", i))
.build()
.expect("failed to build background CPU pool"),
)
}))
}
fn output_cleanup_guard(self: &Arc<Self>, output_id: SegmentId) -> OutputCleanupGuard {
let manager = Arc::clone(self);
let cleanup: Arc<dyn Fn(SegmentId) + Send + Sync> = Arc::new(move |segment_id| {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
log::warn!(
"[segment_cleanup] runtime unavailable; partial output {} will be swept on startup",
segment_id.to_hex(),
);
return;
};
let manager = Arc::clone(&manager);
handle.spawn(async move {
manager
.delete_output_if_unregistered(segment_id, "task unwind")
.await;
});
});
OutputCleanupGuard::new(output_id, cleanup)
}
async fn delete_output_if_unregistered(&self, output_id: SegmentId, reason: &str) {
let output_hex = output_id.to_hex();
let st = self.state.lock().await;
if st.metadata.has_segment(&output_hex) {
return;
}
log::warn!(
"[segment_cleanup] deleting uncommitted output {} after {}",
output_hex,
reason,
);
let _ = crate::segment::delete_segment(self.directory.as_ref(), output_id).await;
drop(st);
}
pub async fn get_segment_ids(&self) -> Vec<String> {
self.state.lock().await.metadata.segment_ids()
}
pub fn trained(&self) -> Option<Arc<TrainedVectorStructures>> {
self.trained.load_full()
}
pub async fn load_and_publish_trained(&self) {
let vector_fields = {
let st = self.state.lock().await;
st.metadata.vector_fields.clone()
};
let trained =
IndexMetadata::load_trained_from_fields(&vector_fields, self.directory.as_ref()).await;
if let Some(t) = trained {
self.trained.store(Some(Arc::new(t)));
}
}
pub(crate) fn clear_trained(&self) {
self.trained.store(None);
}
pub(crate) async fn read_metadata<F, R>(&self, f: F) -> R
where
F: FnOnce(&IndexMetadata) -> R,
{
let st = self.state.lock().await;
f(&st.metadata)
}
pub(crate) async fn update_metadata<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut IndexMetadata),
{
let mut st = self.state.lock().await;
f(&mut st.metadata);
st.metadata.save(self.directory.as_ref()).await
}
pub async fn acquire_snapshot(&self) -> SegmentSnapshot {
let acquired = {
let st = self.state.lock().await;
let segment_ids = st.metadata.segment_ids();
self.tracker.acquire(&segment_ids)
};
SegmentSnapshot::with_delete_fn(
Arc::clone(&self.tracker),
acquired,
Arc::clone(&self.delete_fn),
)
}
pub fn tracker(&self) -> Arc<SegmentTracker> {
Arc::clone(&self.tracker)
}
pub fn directory(&self) -> Arc<D> {
Arc::clone(&self.directory)
}
}
#[cfg(feature = "native")]
impl<D: DirectoryWriter + 'static> SegmentManager<D> {
pub async fn commit(&self, new_segments: Vec<(String, u32)>) -> Result<()> {
let mut st = self.state.lock().await;
for (segment_id, num_docs) in new_segments {
if !st.metadata.has_segment(&segment_id) {
st.metadata.add_segment(segment_id.clone(), num_docs);
self.tracker.register(&segment_id);
}
}
st.metadata.save(self.directory.as_ref()).await
}
pub async fn maybe_merge(self: &Arc<Self>) {
let slots_available = {
let mut handles = self.merge_handles.lock().await;
handles.retain(|h| !h.is_finished());
self.max_concurrent_merges.saturating_sub(handles.len())
};
if slots_available == 0 {
log::debug!("[maybe_merge] at max concurrent merges, skipping");
return;
}
let new_handles = {
let st = self.state.lock().await;
let segments: Vec<SegmentInfo> = st
.metadata
.segment_metas
.iter()
.filter(|(id, _)| {
!self.tracker.is_pending_deletion(id) && !self.merge_inventory.contains(id)
})
.map(|(id, info)| SegmentInfo {
id: id.clone(),
num_docs: info.num_docs,
})
.collect();
log::debug!("[maybe_merge] {} eligible segments", segments.len());
let candidates = st.merge_policy.find_merges(&segments);
if candidates.is_empty() {
return;
}
log::debug!(
"[maybe_merge] {} merge candidates, {} slots available",
candidates.len(),
slots_available
);
let mut handles = Vec::new();
for c in candidates {
if handles.len() >= slots_available {
break;
}
if let Some(h) = self.spawn_merge(c.segment_ids) {
handles.push(h);
}
}
handles
};
if !new_handles.is_empty() {
self.merge_handles.lock().await.extend(new_handles);
}
}
fn spawn_merge(self: &Arc<Self>, segment_ids_to_merge: Vec<String>) -> Option<JoinHandle<()>> {
let output_id = SegmentId::new();
let output_hex = output_id.to_hex();
let mut all_ids = segment_ids_to_merge.clone();
all_ids.push(output_hex);
let guard = match self.merge_inventory.try_register(all_ids) {
Some(g) => g,
None => {
log::debug!("[spawn_merge] skipped: segments overlap with active merge");
return None;
}
};
let sm = Arc::clone(self);
let ids = segment_ids_to_merge;
Some(tokio::spawn(async move {
let _guard = guard;
let mut output_cleanup = sm.output_cleanup_guard(output_id);
let trained_snap = sm.trained();
let granularity = sm.merge_granularity(&ids).await;
let result = Self::do_merge(
sm.directory.as_ref(),
&sm.schema,
&ids,
output_id,
sm.term_cache_blocks,
trained_snap.as_deref(),
sm.reorder_on_merge,
granularity,
sm.merge_bp_time_budget,
sm.bp_memory_budget_bytes,
Some(sm.background_cpu_pool()),
)
.await;
match result {
Ok((new_id, doc_count, bp_converged)) => {
match sm
.replace_segments(
&ids,
new_id,
doc_count,
sm.reorder_on_merge,
bp_converged,
)
.await
{
Ok(()) => output_cleanup.disarm(),
Err(e) => {
sm.delete_output_if_unregistered(output_id, "replacement failure")
.await;
output_cleanup.disarm();
log::error!("[merge] Failed to replace segments after merge: {:?}", e);
}
}
}
Err(e) => {
log::error!(
"[merge] Background merge failed for segments {:?}: {:?}",
ids,
e
);
let _ = crate::segment::delete_segment(sm.directory.as_ref(), output_id).await;
output_cleanup.disarm();
}
}
sm.maybe_merge().await;
}))
}
async fn replace_segments(
&self,
old_ids: &[String],
new_id: String,
doc_count: u32,
reordered: bool,
bp_converged: bool,
) -> Result<()> {
let ready_to_delete = {
let mut st = self.state.lock().await;
let missing: Vec<&String> = old_ids
.iter()
.filter(|id| !st.metadata.has_segment(id))
.collect();
if !missing.is_empty() {
return Err(Error::Corruption(format!(
"replace_segments: source segment(s) {:?} not in metadata — \
refusing to add output {} (would duplicate documents)",
missing, new_id
)));
}
self.tracker.register(&new_id);
let parent_gen = old_ids
.iter()
.filter_map(|id| st.metadata.segment_metas.get(id))
.map(|info| info.generation)
.max()
.unwrap_or(0);
let ancestors: Vec<String> = old_ids.to_vec();
for id in old_ids {
st.metadata.remove_segment(id);
}
st.metadata.add_merged_segment(
new_id,
doc_count,
ancestors,
parent_gen + 1,
reordered,
bp_converged,
);
st.metadata.save(self.directory.as_ref()).await?;
self.tracker.mark_for_deletion(old_ids)
};
for &segment_id in &ready_to_delete {
let _ = crate::segment::delete_segment(self.directory.as_ref(), segment_id).await;
}
self.tracker.complete_deletion(&ready_to_delete);
Ok(())
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn do_merge(
directory: &D,
schema: &Arc<crate::dsl::Schema>,
segment_ids_to_merge: &[String],
output_segment_id: SegmentId,
term_cache_blocks: usize,
trained: Option<&TrainedVectorStructures>,
reorder_bmp: bool,
granularity: crate::segment::reorder::BpGranularity,
merge_bp_time_budget: Option<std::time::Duration>,
bp_memory_budget_bytes: usize,
bg_cpu_pool: Option<Arc<rayon::ThreadPool>>,
) -> Result<(String, u32, bool)> {
let output_hex = output_segment_id.to_hex();
let load_start = std::time::Instant::now();
let segment_ids: Vec<SegmentId> = segment_ids_to_merge
.iter()
.map(|id_str| {
SegmentId::from_hex(id_str)
.ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", id_str)))
})
.collect::<Result<Vec<_>>>()?;
let schema_arc = Arc::clone(schema);
let futures: Vec<_> = segment_ids
.iter()
.map(|&sid| {
let sch = Arc::clone(&schema_arc);
async move { SegmentReader::open(directory, sid, sch, term_cache_blocks).await }
})
.collect();
let results = futures::future::join_all(futures).await;
let mut readers = Vec::with_capacity(results.len());
let mut total_docs = 0u64;
for (i, result) in results.into_iter().enumerate() {
match result {
Ok(r) => {
total_docs += r.meta().num_docs as u64;
readers.push(r);
}
Err(e) => {
log::error!(
"[merge] Failed to open segment {}: {:?}",
segment_ids_to_merge[i],
e
);
return Err(e);
}
}
}
for (i, reader) in readers.iter().enumerate() {
let meta_docs = reader.meta().num_docs;
let store_docs = reader.store().num_docs();
if store_docs != meta_docs {
return Err(Error::Corruption(format!(
"pre-merge validation: segment {} store has {} docs but meta says {}",
segment_ids_to_merge[i], store_docs, meta_docs
)));
}
}
log::info!(
"[merge] loaded {} segment readers in {:.1}s",
readers.len(),
load_start.elapsed().as_secs_f64()
);
let merger = SegmentMerger::new(Arc::clone(schema))
.with_bmp_reorder(reorder_bmp)
.with_granularity(granularity)
.with_bp_budget(crate::segment::BpBudget {
min_partition_docs: None,
time_budget: merge_bp_time_budget,
})
.with_bp_memory_budget(bp_memory_budget_bytes)
.with_background_pool(bg_cpu_pool);
log::info!(
"[merge] {} segments -> {} (trained={})",
segment_ids_to_merge.len(),
output_hex,
trained.map_or(0, |t| t.centroids.len()),
);
let (_merged_meta, merge_stats) = merger
.merge(directory, &readers, output_segment_id, trained)
.await?;
let bp_converged = merge_stats.bp_converged;
if !bp_converged {
log::info!(
"[merge] merge-time BP hit its wall-clock budget — output marked unconverged; the background optimizer deepens it later",
);
}
log::info!(
"[merge] total wall-clock: {:.1}s ({} segments, {} docs)",
load_start.elapsed().as_secs_f64(),
readers.len(),
total_docs,
);
if total_docs > u32::MAX as u64 {
return Err(Error::Internal(format!(
"Merged segment doc count ({}) exceeds u32::MAX",
total_docs
)));
}
Ok((output_hex, total_docs as u32, bp_converged))
}
pub async fn abort_merges(&self) {
let handles: Vec<JoinHandle<()>> =
{ std::mem::take(&mut *self.merge_handles.lock().await) };
for h in handles {
h.abort();
}
}
pub async fn wait_for_merging_thread(self: &Arc<Self>) {
let handles: Vec<JoinHandle<()>> =
{ std::mem::take(&mut *self.merge_handles.lock().await) };
for h in handles {
let _ = h.await;
}
}
pub async fn wait_for_all_merges(self: &Arc<Self>) {
loop {
let handles: Vec<JoinHandle<()>> =
{ std::mem::take(&mut *self.merge_handles.lock().await) };
if handles.is_empty() {
break;
}
for h in handles {
let _ = h.await;
}
}
}
pub async fn force_merge(self: &Arc<Self>) -> Result<()> {
const FORCE_MERGE_BATCH: usize = 64;
let max_segment_docs = {
let st = self.state.lock().await;
st.merge_policy.max_segment_docs()
};
self.wait_for_all_merges().await;
loop {
let mut segments: Vec<(String, u32)> = {
let st = self.state.lock().await;
st.metadata
.segment_metas
.iter()
.map(|(id, info)| (id.clone(), info.num_docs))
.collect()
};
if segments.len() < 2 {
return Ok(());
}
segments.sort_by_key(|(_, docs)| *docs);
let max_docs = max_segment_docs.map(|m| m as u64).unwrap_or(u64::MAX);
let mut batch = Vec::new();
let mut batch_docs = 0u64;
for (id, docs) in &segments {
if batch.len() >= FORCE_MERGE_BATCH {
break;
}
let next_total = batch_docs + *docs as u64;
if next_total > max_docs && !batch.is_empty() {
break;
}
batch.push(id.clone());
batch_docs += *docs as u64;
}
if batch.len() < 2 {
return Ok(());
}
log::info!(
"[force_merge] merging batch of {} segments ({} docs)",
batch.len(),
batch_docs
);
let output_id = SegmentId::new();
let output_hex = output_id.to_hex();
let mut all_ids = batch.clone();
all_ids.push(output_hex);
let guard = {
let st = self.state.lock().await;
batch
.iter()
.all(|id| st.metadata.has_segment(id))
.then(|| self.merge_inventory.try_register(all_ids))
.flatten()
};
let _guard = match guard {
Some(g) => g,
None => {
self.wait_for_merging_thread().await;
continue;
}
};
let mut output_cleanup = self.output_cleanup_guard(output_id);
let trained_snap = self.trained();
let granularity = self.merge_granularity(&batch).await;
let merge_result = Self::do_merge(
self.directory.as_ref(),
&self.schema,
&batch,
output_id,
self.term_cache_blocks,
trained_snap.as_deref(),
self.reorder_on_merge,
granularity,
self.merge_bp_time_budget,
self.bp_memory_budget_bytes,
Some(self.background_cpu_pool()),
)
.await;
let (new_segment_id, total_docs, bp_converged) = match merge_result {
Ok(v) => v,
Err(e) => {
let _ =
crate::segment::delete_segment(self.directory.as_ref(), output_id).await;
output_cleanup.disarm();
return Err(e);
}
};
if let Err(e) = self
.replace_segments(
&batch,
new_segment_id,
total_docs,
self.reorder_on_merge,
bp_converged,
)
.await
{
self.delete_output_if_unregistered(output_id, "replacement failure")
.await;
output_cleanup.disarm();
return Err(e);
}
output_cleanup.disarm();
}
}
pub async fn reorder_segments(self: &Arc<Self>) -> Result<()> {
self.wait_for_all_merges().await;
let segment_ids = self.get_segment_ids().await;
if segment_ids.is_empty() {
log::info!("[reorder] no segments to reorder");
return Ok(());
}
log::info!("[reorder] reordering {} segments", segment_ids.len());
for seg_id in segment_ids {
match self
.reorder_single_segment(&seg_id, None, crate::segment::BpBudget::full())
.await
{
Ok(true) => {}
Ok(false) => log::warn!("[reorder] segment {} skipped (in merge)", seg_id),
Err(e) => return Err(e),
}
}
log::info!("[reorder] all segments reordered");
Ok(())
}
pub async fn unreordered_segment_ids(&self) -> Vec<String> {
let st = self.state.lock().await;
let in_merge = self.merge_inventory.snapshot();
st.metadata
.segment_metas
.iter()
.filter(|(id, info)| !info.reordered && !in_merge.contains(*id))
.map(|(id, _)| id.clone())
.collect()
}
pub async fn unreordered_segments(&self) -> Vec<(String, u32)> {
let st = self.state.lock().await;
let in_merge = self.merge_inventory.snapshot();
st.metadata
.segment_metas
.iter()
.filter(|(id, info)| !info.reordered && !in_merge.contains(*id))
.map(|(id, info)| (id.clone(), info.num_docs))
.collect()
}
pub async fn unconverged_segments(&self) -> Vec<(String, u32)> {
let st = self.state.lock().await;
let in_merge = self.merge_inventory.snapshot();
st.metadata
.segment_metas
.iter()
.filter(|(id, info)| info.reordered && !info.bp_converged && !in_merge.contains(*id))
.map(|(id, info)| (id.clone(), info.num_docs))
.collect()
}
async fn merge_granularity(&self, ids: &[String]) -> crate::segment::reorder::BpGranularity {
let st = self.state.lock().await;
let deepening = ids.iter().any(|id| {
st.metadata
.segment_metas
.get(id)
.is_some_and(|info| info.reordered && !info.bp_converged)
});
drop(st);
if deepening {
log::info!(
"[reorder] source segment(s) unconverged — forcing record-level BP (deepening pass)",
);
crate::segment::reorder::BpGranularity::Records
} else {
crate::segment::reorder::BpGranularity::Auto
}
}
pub async fn reorder_single_segment(
self: &Arc<Self>,
seg_id: &str,
rayon_pool: Option<Arc<rayon::ThreadPool>>,
bp_budget: crate::segment::BpBudget,
) -> Result<bool> {
let source_id = SegmentId::from_hex(seg_id)
.ok_or_else(|| Error::Corruption(format!("Invalid segment ID: {}", seg_id)))?;
let output_id = SegmentId::new();
let output_hex = output_id.to_hex();
let source_ids = [seg_id.to_string()];
let granularity = self.merge_granularity(&source_ids).await;
let all_ids = vec![seg_id.to_string(), output_hex];
let _guard = {
let st = self.state.lock().await;
if !st.metadata.has_segment(seg_id) {
log::info!(
"[optimizer] segment {} no longer in metadata (merged away), skipping reorder",
seg_id
);
return Ok(false);
}
match self.merge_inventory.try_register(all_ids) {
Some(guard) => guard,
None => {
log::debug!("[optimizer] segment {} in active merge, skipping", seg_id);
return Ok(false);
}
}
};
let mut output_cleanup = self.output_cleanup_guard(output_id);
let reorder_result = crate::segment::reorder::reorder_segment(
self.directory.as_ref(),
&self.schema,
source_id,
output_id,
self.term_cache_blocks,
self.bp_memory_budget_bytes,
bp_budget,
granularity,
rayon_pool,
)
.await;
let (new_id, total_docs, bp_converged) = match reorder_result {
Ok(v) => v,
Err(e) => {
let _ = crate::segment::delete_segment(self.directory.as_ref(), output_id).await;
output_cleanup.disarm();
return Err(e);
}
};
let ladder_converged = bp_converged && bp_budget.min_partition_docs.is_none();
if let Err(e) = self
.replace_segments(
&[seg_id.to_string()],
new_id,
total_docs,
true,
ladder_converged,
)
.await
{
self.delete_output_if_unregistered(output_id, "replacement failure")
.await;
output_cleanup.disarm();
return Err(e);
}
output_cleanup.disarm();
Ok(true)
}
pub async fn cleanup_orphan_segments(&self) -> Result<usize> {
let mut orphan_ids: HashSet<String> = HashSet::new();
if let Ok(entries) = self.directory.list_files(std::path::Path::new("")).await {
for entry in entries {
let filename = entry.to_string_lossy();
if filename.starts_with("seg_") && filename.len() > 37 {
let hex_part = &filename[4..36];
orphan_ids.insert(hex_part.to_string());
}
}
}
let mut deleted = 0;
for hex_id in &orphan_ids {
let st = self.state.lock().await;
if st.metadata.has_segment(hex_id)
|| self.merge_inventory.contains(hex_id)
|| self.tracker.is_deletion_protected(hex_id)
{
continue;
}
let removed = if let Some(segment_id) = SegmentId::from_hex(hex_id) {
crate::segment::delete_segment(self.directory.as_ref(), segment_id)
.await
.is_ok()
} else {
false
};
drop(st);
if removed {
deleted += 1;
}
}
Ok(deleted)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
#[test]
fn output_cleanup_guard_runs_during_panic_unwind() {
let cleaned = Arc::new(AtomicBool::new(false));
let cleaned_in_callback = Arc::clone(&cleaned);
let cleanup: Arc<dyn Fn(SegmentId) + Send + Sync> = Arc::new(move |_| {
cleaned_in_callback.store(true, Ordering::SeqCst);
});
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = OutputCleanupGuard::new(SegmentId::new(), cleanup);
panic!("simulated reorder panic");
}));
assert!(result.is_err());
assert!(
cleaned.load(Ordering::SeqCst),
"partial output cleanup must run during unwind"
);
}
#[test]
fn output_cleanup_guard_disarms_after_commit() {
let cleaned = Arc::new(AtomicBool::new(false));
let cleaned_in_callback = Arc::clone(&cleaned);
let cleanup: Arc<dyn Fn(SegmentId) + Send + Sync> = Arc::new(move |_| {
cleaned_in_callback.store(true, Ordering::SeqCst);
});
{
let mut guard = OutputCleanupGuard::new(SegmentId::new(), cleanup);
guard.disarm();
}
assert!(!cleaned.load(Ordering::SeqCst));
}
#[test]
fn test_inventory_guard_drop_unregisters() {
let inv = Arc::new(MergeInventory::new());
{
let _guard = inv.try_register(vec!["a".into(), "b".into()]).unwrap();
let snap = inv.snapshot();
assert!(snap.contains("a"));
assert!(snap.contains("b"));
}
assert!(inv.snapshot().is_empty());
}
#[test]
fn test_inventory_concurrent_non_overlapping_merges() {
let inv = Arc::new(MergeInventory::new());
let _g1 = inv.try_register(vec!["a".into(), "b".into()]).unwrap();
let _g2 = inv.try_register(vec!["c".into(), "d".into()]).unwrap();
let snap = inv.snapshot();
assert_eq!(snap.len(), 4);
drop(_g1);
let snap = inv.snapshot();
assert_eq!(snap.len(), 2);
assert!(snap.contains("c"));
assert!(snap.contains("d"));
}
#[test]
fn test_inventory_overlapping_merge_rejected() {
let inv = Arc::new(MergeInventory::new());
let _g1 = inv.try_register(vec!["a".into(), "b".into()]).unwrap();
assert!(inv.try_register(vec!["b".into(), "c".into()]).is_none());
drop(_g1);
assert!(inv.try_register(vec!["b".into(), "c".into()]).is_some());
}
#[test]
fn test_inventory_snapshot() {
let inv = Arc::new(MergeInventory::new());
let _g = inv.try_register(vec!["x".into(), "y".into()]).unwrap();
let snap = inv.snapshot();
assert!(snap.contains("x"));
assert!(snap.contains("y"));
assert!(!snap.contains("z"));
}
}