use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use arc_swap::ArcSwapOption;
use tokio::sync::Mutex as AsyncMutex;
use tokio::sync::{Notify, Semaphore};
use tokio::task::JoinHandle;
use crate::directories::DirectoryWriter;
use crate::error::{Error, Result};
use crate::index::IndexMetadata;
use crate::segment::{
SegmentFiles, SegmentId, SegmentMeta, SegmentSnapshot, SegmentTracker, TrainedVectorStructures,
};
#[cfg(feature = "native")]
use crate::segment::{SegmentMerger, SegmentReader};
use super::{MergePolicy, SegmentInfo};
struct ActiveOperationState {
segment_ids: HashSet<String>,
accepting: bool,
}
struct ActiveSegmentOperations {
inner: parking_lot::Mutex<ActiveOperationState>,
idle: Notify,
shutdown: Notify,
}
impl ActiveSegmentOperations {
fn new() -> Self {
Self {
inner: parking_lot::Mutex::new(ActiveOperationState {
segment_ids: HashSet::new(),
accepting: true,
}),
idle: Notify::new(),
shutdown: Notify::new(),
}
}
fn try_register(self: &Arc<Self>, segment_ids: Vec<String>) -> Option<SegmentOperationGuard> {
let mut inner = self.inner.lock();
if !inner.accepting {
log::debug!("[segment_lifecycle] rejected operation during shutdown");
return None;
}
for id in &segment_ids {
if inner.segment_ids.contains(id) {
log::debug!(
"[segment_lifecycle] rejected: {} overlaps with an active operation ({} active IDs)",
id,
inner.segment_ids.len()
);
return None;
}
}
log::debug!(
"[segment_lifecycle] registered {} IDs (total active: {})",
segment_ids.len(),
inner.segment_ids.len() + segment_ids.len()
);
for id in &segment_ids {
inner.segment_ids.insert(id.clone());
}
Some(SegmentOperationGuard {
active_operations: Arc::clone(self),
segment_ids,
})
}
fn snapshot(&self) -> HashSet<String> {
self.inner.lock().segment_ids.clone()
}
fn stop_accepting(&self) {
let mut inner = self.inner.lock();
inner.accepting = false;
self.shutdown.notify_waiters();
if inner.segment_ids.is_empty() {
self.idle.notify_waiters();
}
}
fn is_accepting(&self) -> bool {
self.inner.lock().accepting
}
async fn wait_until_idle(&self) {
loop {
let notified = self.idle.notified();
if self.inner.lock().segment_ids.is_empty() {
return;
}
notified.await;
}
}
async fn wait_for_shutdown(&self) {
loop {
let notified = self.shutdown.notified();
if !self.inner.lock().accepting {
return;
}
notified.await;
}
}
}
pub(crate) struct SegmentOperationGuard {
active_operations: Arc<ActiveSegmentOperations>,
segment_ids: Vec<String>,
}
impl Drop for SegmentOperationGuard {
fn drop(&mut self) {
let mut inner = self.active_operations.inner.lock();
for id in &self.segment_ids {
inner.segment_ids.remove(id);
}
if inner.segment_ids.is_empty() {
self.active_operations.idle.notify_waiters();
}
}
}
static BACKGROUND_CPU_POOL: OnceLock<Arc<rayon::ThreadPool>> = OnceLock::new();
const MERGE_RETRY_BASE_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
const MERGE_RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30 * 60);
#[derive(Default)]
struct MergeRetryState {
retry_after: Option<std::time::Instant>,
consecutive_failures: u32,
}
fn merge_retry_delay(consecutive_failures: u32) -> std::time::Duration {
let shift = consecutive_failures.saturating_sub(1).min(16);
MERGE_RETRY_BASE_DELAY
.checked_mul(1u32 << shift)
.unwrap_or(MERGE_RETRY_MAX_DELAY)
.min(MERGE_RETRY_MAX_DELAY)
}
fn try_spawn_lifecycle<F>(
handles: &parking_lot::Mutex<Vec<JoinHandle<()>>>,
runtime: &tokio::runtime::Handle,
future: F,
) -> bool
where
F: std::future::Future<Output = ()> + Send + 'static,
{
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut handles = handles.lock();
handles.retain(|handle| !handle.is_finished());
handles.push(runtime.spawn(future));
}))
.is_ok()
}
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>,
}
#[cfg(feature = "native")]
struct MergeTaskError {
error: Error,
unavailable_segments: Vec<String>,
}
#[cfg(feature = "native")]
impl MergeTaskError {
fn source(segment_id: String, error: Error) -> Self {
Self {
error,
unavailable_segments: vec![segment_id],
}
}
fn sources(segment_ids: Vec<String>, error: Error) -> Self {
Self {
error,
unavailable_segments: segment_ids,
}
}
}
#[cfg(feature = "native")]
impl From<Error> for MergeTaskError {
fn from(error: Error) -> Self {
Self {
error,
unavailable_segments: Vec::new(),
}
}
}
#[cfg(feature = "native")]
fn is_deterministic_source_error(error: &Error) -> bool {
matches!(error, Error::Corruption(_) | Error::Serialization(_))
|| matches!(error, Error::Io(error) if error.kind() == std::io::ErrorKind::NotFound)
}
#[cfg(feature = "native")]
fn classify_source_error(segment_id: String, error: Error) -> MergeTaskError {
if is_deterministic_source_error(&error) {
MergeTaskError::source(segment_id, error)
} else {
MergeTaskError::from(error)
}
}
#[cfg(feature = "native")]
type MergeTaskResult<T> = std::result::Result<T, MergeTaskError>;
pub struct SegmentManager<D: DirectoryWriter + 'static> {
state: Arc<AsyncMutex<ManagerState>>,
active_operations: Arc<ActiveSegmentOperations>,
quarantined_segments: parking_lot::Mutex<HashSet<String>>,
merge_retry: parking_lot::Mutex<MergeRetryState>,
reorder_retries: parking_lot::Mutex<HashMap<String, MergeRetryState>>,
merge_handles: parking_lot::Mutex<Vec<JoinHandle<()>>>,
global_merge_wakeup_pending: AtomicBool,
lifecycle_handles: Arc<parking_lot::Mutex<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,
merge_permits: Arc<Semaphore>,
global_merge_permits: Arc<Semaphore>,
reorder_permits: Arc<Semaphore>,
reorder_on_merge: bool,
merge_bp_time_budget: Option<std::time::Duration>,
bp_memory_budget_bytes: usize,
background_reorder_pool: Option<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,
global_merge_permits: Arc<Semaphore>,
merge_bp_time_budget: Option<std::time::Duration>,
bp_memory_budget_bytes: usize,
reorder_permits: Arc<Semaphore>,
background_reorder_pool: Option<Arc<rayon::ThreadPool>>,
) -> 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 lifecycle_handles: Arc<parking_lot::Mutex<Vec<JoinHandle<()>>>> =
Arc::new(parking_lot::Mutex::new(Vec::new()));
let delete_fn: Arc<dyn Fn(Vec<SegmentId>) + Send + Sync> = {
let dir = Arc::clone(&directory);
let tracker = Arc::clone(&tracker);
let lifecycle_handles = Arc::clone(&lifecycle_handles);
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 task_tracker = Arc::clone(&tracker);
let cleanup_ids = segment_ids.clone();
let future = async move {
for &segment_id in &segment_ids {
log::info!(
"[segment_cleanup] deleting deferred segment {}",
segment_id.to_hex()
);
if let Err(error) =
crate::segment::delete_segment(dir.as_ref(), segment_id).await
{
log::warn!(
"[segment_cleanup] deferred delete failed for {}: {}",
segment_id.to_hex(),
error,
);
}
}
task_tracker.complete_deletion(&segment_ids);
};
if !try_spawn_lifecycle(&lifecycle_handles, &handle, future) {
tracker.complete_deletion(&cleanup_ids);
log::warn!(
"[segment_cleanup] runtime rejected deferred deletion; files will be swept later"
);
}
})
};
Self {
state: Arc::new(AsyncMutex::new(ManagerState {
metadata,
merge_policy,
})),
active_operations: Arc::new(ActiveSegmentOperations::new()),
quarantined_segments: parking_lot::Mutex::new(HashSet::new()),
merge_retry: parking_lot::Mutex::new(MergeRetryState::default()),
reorder_retries: parking_lot::Mutex::new(HashMap::new()),
merge_handles: parking_lot::Mutex::new(Vec::new()),
global_merge_wakeup_pending: AtomicBool::new(false),
lifecycle_handles,
trained: ArcSwapOption::new(None),
tracker,
delete_fn,
directory,
schema,
term_cache_blocks,
merge_permits: Arc::new(Semaphore::new(max_concurrent_merges.max(1))),
global_merge_permits,
reorder_permits,
reorder_on_merge,
merge_bp_time_budget,
bp_memory_budget_bytes,
background_reorder_pool,
}
}
pub fn background_cpu_pool(&self) -> Arc<rayon::ThreadPool> {
if let Some(pool) = &self.background_reorder_pool {
return Arc::clone(pool);
}
Arc::clone(BACKGROUND_CPU_POOL.get_or_init(|| {
let threads = (num_cpus::get() / 2).max(1);
log::info!(
"[merge] process-wide 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"),
)
}))
}
pub fn begin_shutdown(&self) {
self.active_operations.stop_accepting();
}
async fn run_lifecycle_transaction<T, F>(&self, transaction: F) -> Result<T>
where
T: Send + 'static,
F: std::future::Future<Output = Result<T>> + Send + 'static,
{
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let future = async move {
let result = transaction.await;
let _ = result_tx.send(result);
};
let runtime = tokio::runtime::Handle::current();
if !try_spawn_lifecycle(&self.lifecycle_handles, &runtime, future) {
return Err(Error::Internal(
"runtime rejected lifecycle metadata transaction".into(),
));
}
result_rx.await.map_err(|_| {
Error::Internal("lifecycle metadata transaction terminated unexpectedly".into())
})?
}
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 cleanup_manager = Arc::clone(&manager);
let future = async move {
cleanup_manager
.delete_output_if_unregistered(segment_id, "task unwind")
.await;
};
if !try_spawn_lifecycle(&manager.lifecycle_handles, &handle, future) {
log::warn!(
"[segment_cleanup] runtime rejected output cleanup; {} will be swept on startup",
segment_id.to_hex(),
);
}
});
OutputCleanupGuard::new(output_id, cleanup)
}
pub(crate) fn schedule_unpublished_segment_cleanup(
self: &Arc<Self>,
output_id: SegmentId,
operation: SegmentOperationGuard,
runtime: tokio::runtime::Handle,
) {
let manager = Arc::clone(self);
let output_hex = output_id.to_hex();
let future = async move {
manager
.delete_output_if_unregistered(output_id, "indexing abort or failure")
.await;
drop(operation);
};
if !try_spawn_lifecycle(&self.lifecycle_handles, &runtime, future) {
log::warn!(
"[segment_cleanup] runtime unavailable; indexing output {} will be swept on startup",
output_hex,
);
}
}
pub(crate) fn protect_new_segment(&self, segment_id: String) -> Result<SegmentOperationGuard> {
match self
.active_operations
.try_register(vec![segment_id.clone()])
{
Some(operation) => Ok(operation),
None if !self.active_operations.is_accepting() => Err(Error::IndexClosed),
None => Err(Error::Corruption(format!(
"new segment ID {} is already owned by an active operation",
segment_id
))),
}
}
async fn validate_completed_segment(&self, segment_id: &str, expected_docs: u32) -> Result<()> {
let id = SegmentId::from_hex(segment_id).ok_or_else(|| {
Error::Corruption(format!("invalid completed segment ID: {}", segment_id))
})?;
let files = SegmentFiles::new(id.0);
for path in files.mandatory_paths() {
if !self.directory.exists(path).await.map_err(Error::Io)? {
return Err(Error::Corruption(format!(
"segment {} cannot be published: mandatory file {:?} is missing",
segment_id, path
)));
}
}
let meta_slice = self.directory.open_read(&files.meta).await.map_err(|e| {
Error::Corruption(format!(
"segment {} cannot be published: missing/unreadable {:?}: {}",
segment_id, files.meta, e
))
})?;
let meta_bytes = meta_slice.read_bytes().await.map_err(|e| {
Error::Corruption(format!(
"segment {} cannot be published: failed reading {:?}: {}",
segment_id, files.meta, e
))
})?;
let meta = SegmentMeta::deserialize(meta_bytes.as_slice()).map_err(|e| {
Error::Corruption(format!(
"segment {} cannot be published: invalid {:?}: {}",
segment_id, files.meta, e
))
})?;
if meta.id != id.0 || meta.num_docs != expected_docs {
return Err(Error::Corruption(format!(
"segment {} cannot be published: metadata identity/docs mismatch \
(id={:032x}, docs={}, expected_docs={})",
segment_id, meta.id, meta.num_docs, expected_docs
)));
}
Ok(())
}
fn quarantine_segment(&self, segment_id: &str, error: &Error) {
let inserted = self
.quarantined_segments
.lock()
.insert(segment_id.to_string());
if inserted {
log::error!(
"[merge] quarantined metadata-live segment {} after deterministic source/validation failure: {}. \
It remains metadata-live for explicit repair but is excluded from merges until restart",
segment_id,
error,
);
}
}
fn pause_merge_retries(&self, error: &Error) -> std::time::Duration {
let mut retry = self.merge_retry.lock();
retry.consecutive_failures = retry.consecutive_failures.saturating_add(1);
let delay = merge_retry_delay(retry.consecutive_failures);
retry.retry_after = std::time::Instant::now().checked_add(delay);
log::warn!(
"[merge] pausing background merge scheduling for {:.0}s after consecutive failure #{}: {}",
delay.as_secs_f64(),
retry.consecutive_failures,
error,
);
delay
}
fn clear_merge_retry_backoff(&self) {
*self.merge_retry.lock() = MergeRetryState::default();
}
fn merge_retry_is_paused(&self) -> bool {
let mut retry = self.merge_retry.lock();
match retry.retry_after {
Some(deadline) if deadline > std::time::Instant::now() => true,
Some(_) => {
retry.retry_after = None;
false
}
None => false,
}
}
fn pause_reorder_retries(&self, segment_id: &str, error: &Error) {
let mut retries = self.reorder_retries.lock();
let retry = retries.entry(segment_id.to_string()).or_default();
retry.consecutive_failures = retry.consecutive_failures.saturating_add(1);
let delay = merge_retry_delay(retry.consecutive_failures);
retry.retry_after = std::time::Instant::now().checked_add(delay);
log::warn!(
"[reorder] pausing optimizer retries for segment {} for {:.0}s after failure #{}: {}",
segment_id,
delay.as_secs_f64(),
retry.consecutive_failures,
error,
);
}
fn clear_reorder_retry(&self, segment_id: &str) {
self.reorder_retries.lock().remove(segment_id);
}
fn paused_reorder_segments(&self) -> HashSet<String> {
let now = std::time::Instant::now();
let mut retries = self.reorder_retries.lock();
let mut paused = HashSet::new();
for (segment_id, retry) in retries.iter_mut() {
match retry.retry_after {
Some(deadline) if deadline > now => {
paused.insert(segment_id.clone());
}
Some(_) => retry.retry_after = None,
None => {}
}
}
paused
}
fn schedule_global_merge_wakeup(self: &Arc<Self>) {
if self
.global_merge_wakeup_pending
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
let manager = Arc::clone(self);
let future = async move {
let capacity = tokio::select! {
biased;
() = manager.active_operations.wait_for_shutdown() => None,
permit = Arc::clone(&manager.global_merge_permits).acquire_owned() => permit.ok(),
};
manager
.global_merge_wakeup_pending
.store(false, Ordering::Release);
if let Some(permit) = capacity {
drop(permit);
manager.maybe_merge().await;
}
};
let runtime = tokio::runtime::Handle::current();
if !try_spawn_lifecycle(&self.lifecycle_handles, &runtime, future) {
self.global_merge_wakeup_pending
.store(false, Ordering::Release);
log::warn!("[merge] runtime rejected global-capacity wakeup task");
}
}
#[cfg(test)]
pub(crate) fn is_segment_quarantined(&self, segment_id: &str) -> bool {
self.quarantined_segments.lock().contains(segment_id)
}
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::info!(
"[segment_cleanup] deleting uncommitted output {} after {}",
output_hex,
reason,
);
if let Err(error) = crate::segment::delete_segment(self.directory.as_ref(), output_id).await
{
log::warn!(
"[segment_cleanup] failed deleting uncommitted output {}: {}",
output_hex,
error,
);
}
}
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: &Arc<Self>, f: F) -> Result<()>
where
F: FnOnce(&mut IndexMetadata),
{
let mut st = Arc::clone(&self.state).lock_owned().await;
let mut next = st.metadata.clone();
f(&mut next);
let directory = Arc::clone(&self.directory);
self.run_lifecycle_transaction(async move {
next.save(directory.as_ref()).await?;
st.metadata = next;
Ok(())
})
.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: &Arc<Self>, new_segments: &[(String, u32)]) -> Result<()> {
for (segment_id, num_docs) in new_segments {
self.validate_completed_segment(segment_id, *num_docs)
.await?;
}
let mut st = Arc::clone(&self.state).lock_owned().await;
let mut next = st.metadata.clone();
let mut added = Vec::new();
for (segment_id, num_docs) in new_segments {
if !next.has_segment(segment_id) {
next.add_segment(segment_id.clone(), *num_docs);
added.push(segment_id.clone());
}
}
let directory = Arc::clone(&self.directory);
let tracker = Arc::clone(&self.tracker);
self.run_lifecycle_transaction(async move {
next.save(directory.as_ref()).await?;
for segment_id in &added {
tracker.register(segment_id);
}
st.metadata = next;
Ok(())
})
.await
}
pub async fn maybe_merge(self: &Arc<Self>) {
if !self.active_operations.is_accepting() {
log::debug!("[maybe_merge] manager is shutting down, skipping");
return;
}
if self.merge_retry_is_paused() {
log::debug!("[maybe_merge] retry backoff active, skipping");
return;
}
{
let mut handles = self.merge_handles.lock();
handles.retain(|h| !h.is_finished());
}
let local_slots = self.merge_permits.available_permits();
let global_slots = self.global_merge_permits.available_permits();
let slots_available = local_slots.min(global_slots);
let new_handles = {
let st = self.state.lock().await;
let quarantined = self.quarantined_segments.lock().clone();
let active_ids = self.active_operations.snapshot();
let segments: Vec<SegmentInfo> = st
.metadata
.segment_metas
.iter()
.filter(|(id, _)| {
!self.tracker.is_pending_deletion(id)
&& !active_ids.contains(*id)
&& !quarantined.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;
}
if slots_available == 0 {
if local_slots > 0 && global_slots == 0 {
self.schedule_global_merge_wakeup();
}
log::debug!("[maybe_merge] at max concurrent merges, skipping");
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().extend(new_handles);
}
}
fn spawn_merge(self: &Arc<Self>, segment_ids_to_merge: Vec<String>) -> Option<JoinHandle<()>> {
let global_merge_permit = match Arc::clone(&self.global_merge_permits).try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
log::debug!("[spawn_merge] skipped: global merge capacity is full");
self.schedule_global_merge_wakeup();
return None;
}
};
let merge_permit = match Arc::clone(&self.merge_permits).try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
log::debug!("[spawn_merge] skipped: no merge permit available");
return None;
}
};
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.active_operations.try_register(all_ids) {
Some(g) => g,
None => {
log::debug!("[spawn_merge] skipped: segments overlap with an active operation");
return None;
}
};
let sm = Arc::clone(self);
let ids = segment_ids_to_merge;
Some(tokio::spawn(async move {
let mut output_cleanup = sm.output_cleanup_guard(output_id);
let mut reevaluate = false;
let mut retry_delay = None;
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,
Arc::clone(&sm.reorder_permits),
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();
sm.clear_merge_retry_backoff();
reevaluate = true;
}
Err(e) => {
sm.delete_output_if_unregistered(output_id, "replacement failure")
.await;
output_cleanup.disarm();
retry_delay = Some(sm.pause_merge_retries(&e));
log::error!("[merge] failed to publish merged segment: {}", e);
}
}
}
Err(MergeTaskError {
error,
unavailable_segments,
}) => {
log::error!(
"[merge] background merge failed for segments {:?}: {}",
ids,
error
);
if !unavailable_segments.is_empty() {
for segment_id in &unavailable_segments {
sm.quarantine_segment(segment_id, &error);
}
reevaluate = true;
} else {
retry_delay = Some(sm.pause_merge_retries(&error));
}
sm.delete_output_if_unregistered(output_id, "merge failure")
.await;
output_cleanup.disarm();
}
}
drop(guard);
drop(merge_permit);
drop(global_merge_permit);
if reevaluate {
sm.maybe_merge().await;
} else if let Some(retry_delay) = retry_delay {
tokio::select! {
() = tokio::time::sleep(retry_delay) => {
sm.maybe_merge().await;
}
() = sm.active_operations.wait_for_shutdown() => {}
}
}
}))
}
async fn replace_segments(
self: &Arc<Self>,
old_ids: &[String],
new_id: String,
doc_count: u32,
reordered: bool,
bp_converged: bool,
) -> Result<()> {
self.validate_completed_segment(&new_id, doc_count).await?;
let mut st = Arc::clone(&self.state).lock_owned().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
)));
}
let parent_generation = old_ids
.iter()
.filter_map(|id| st.metadata.segment_metas.get(id))
.map(|info| info.generation)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| Error::Corruption("merge generation exceeds u32::MAX".into()))?;
let retired_ids = old_ids.to_vec();
let mut next = st.metadata.clone();
for id in old_ids {
next.remove_segment(id);
}
next.add_merged_segment(
new_id.clone(),
doc_count,
retired_ids.clone(),
parent_generation,
reordered,
bp_converged,
);
let directory = Arc::clone(&self.directory);
let tracker = Arc::clone(&self.tracker);
self.run_lifecycle_transaction(async move {
next.save(directory.as_ref()).await?;
tracker.register(&new_id);
st.metadata = next;
let ready_to_delete = tracker.mark_for_deletion(&retired_ids);
drop(st);
for &segment_id in &ready_to_delete {
if let Err(error) =
crate::segment::delete_segment(directory.as_ref(), segment_id).await
{
log::warn!(
"[segment_cleanup] immediate delete failed for {}: {}",
segment_id.to_hex(),
error,
);
}
}
tracker.complete_deletion(&ready_to_delete);
Ok(())
})
.await
}
#[allow(clippy::too_many_arguments)]
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,
reorder_permits: Arc<Semaphore>,
bg_cpu_pool: Option<Arc<rayon::ThreadPool>>,
) -> MergeTaskResult<(String, u32, bool)> {
let output_hex = output_segment_id.to_hex();
let load_start = std::time::Instant::now();
let mut segment_ids = Vec::with_capacity(segment_ids_to_merge.len());
for id_str in segment_ids_to_merge {
let id = SegmentId::from_hex(id_str).ok_or_else(|| {
MergeTaskError::source(
id_str.clone(),
Error::Corruption(format!("Invalid segment ID: {}", id_str)),
)
})?;
segment_ids.push(id);
}
let mut unavailable_sources = Vec::new();
let mut missing_files = Vec::new();
for (id_str, id) in segment_ids_to_merge.iter().zip(&segment_ids) {
let files = SegmentFiles::new(id.0);
let mut source_unavailable = false;
for path in files.mandatory_paths() {
let exists = directory
.exists(path)
.await
.map_err(|error| MergeTaskError::from(Error::Io(error)))?;
if !exists {
source_unavailable = true;
missing_files.push(format!("{}:{:?}", id_str, path));
}
}
if source_unavailable {
unavailable_sources.push(id_str.clone());
}
}
if !unavailable_sources.is_empty() {
return Err(MergeTaskError::sources(
unavailable_sources,
Error::Corruption(format!(
"merge sources are missing mandatory files: {}",
missing_files.join(", ")
)),
));
}
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(classify_source_error(segment_ids_to_merge[i].clone(), e));
}
}
}
if total_docs > u32::MAX as u64 {
return Err(Error::Internal(format!(
"Merged segment doc count ({}) exceeds u32::MAX",
total_docs
))
.into());
}
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(MergeTaskError::source(
segment_ids_to_merge[i].clone(),
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_reorder_permits(reorder_permits)
.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
.map_err(|error| {
if matches!(error, Error::Corruption(_) | Error::Serialization(_)) {
MergeTaskError::sources(segment_ids_to_merge.to_vec(), error)
} else {
MergeTaskError::from(error)
}
})?;
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,
);
Ok((output_hex, total_docs as u32, bp_converged))
}
pub async fn abort_merges(&self) {
loop {
let handles: Vec<JoinHandle<()>> = { std::mem::take(&mut *self.merge_handles.lock()) };
if handles.is_empty() {
return;
}
for handle in handles {
if let Err(error) = handle.await
&& error.is_panic()
{
log::error!("[merge] background task panicked while draining: {}", error);
}
}
}
}
pub async fn wait_for_merging_thread(self: &Arc<Self>) {
let handles: Vec<JoinHandle<()>> = { std::mem::take(&mut *self.merge_handles.lock()) };
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()) };
if handles.is_empty() {
break;
}
for h in handles {
let _ = h.await;
}
}
}
pub async fn wait_for_shutdown(self: &Arc<Self>) {
self.wait_for_all_merges().await;
self.active_operations.wait_until_idle().await;
loop {
let handles = { std::mem::take(&mut *self.lifecycle_handles.lock()) };
if handles.is_empty() {
break;
}
for handle in handles {
if let Err(error) = handle.await
&& error.is_panic()
{
log::error!("[segment_cleanup] task panicked while draining: {}", error);
}
}
}
}
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 {
if !self.active_operations.is_accepting() {
return Err(Error::IndexClosed);
}
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 _global_merge_permit = tokio::select! {
biased;
() = self.active_operations.wait_for_shutdown() => {
return Err(Error::IndexClosed);
}
permit = Arc::clone(&self.global_merge_permits).acquire_owned() => {
permit.map_err(|_| {
Error::Internal("global background merge scheduler is closed".into())
})?
}
};
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.active_operations.try_register(all_ids))
.flatten()
};
let _guard = match guard {
Some(g) => g,
None if !self.active_operations.is_accepting() => {
return Err(Error::IndexClosed);
}
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,
Arc::clone(&self.reorder_permits),
Some(self.background_cpu_pool()),
)
.await;
let (new_segment_id, total_docs, bp_converged) = match merge_result {
Ok(v) => v,
Err(MergeTaskError {
error,
unavailable_segments,
}) => {
for segment_id in &unavailable_segments {
self.quarantine_segment(segment_id, &error);
}
self.delete_output_if_unregistered(output_id, "force-merge failure")
.await;
output_cleanup.disarm();
return Err(error);
}
};
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> {
self.unreordered_segments()
.await
.into_iter()
.map(|(id, _)| id)
.collect()
}
pub async fn unreordered_segments(&self) -> Vec<(String, u32)> {
let quarantined = self.quarantined_segments.lock().clone();
let paused = self.paused_reorder_segments();
let st = self.state.lock().await;
let active_ids = self.active_operations.snapshot();
st.metadata
.segment_metas
.iter()
.filter(|(id, info)| {
!info.reordered
&& !active_ids.contains(*id)
&& !quarantined.contains(*id)
&& !paused.contains(*id)
})
.map(|(id, info)| (id.clone(), info.num_docs))
.collect()
}
pub async fn unconverged_segments(&self) -> Vec<(String, u32)> {
let quarantined = self.quarantined_segments.lock().clone();
let paused = self.paused_reorder_segments();
let st = self.state.lock().await;
let active_ids = self.active_operations.snapshot();
st.metadata
.segment_metas
.iter()
.filter(|(id, info)| {
info.reordered
&& !info.bp_converged
&& !active_ids.contains(*id)
&& !quarantined.contains(*id)
&& !paused.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)))?;
if self.quarantined_segments.lock().contains(seg_id) {
return Err(Error::Corruption(format!(
"segment {} is quarantined after a deterministic source failure; repair it and restart before reordering",
seg_id
)));
}
let _reorder_permit = tokio::select! {
biased;
() = self.active_operations.wait_for_shutdown() => {
return Err(Error::IndexClosed);
}
permit = Arc::clone(&self.reorder_permits).acquire_owned() => {
permit.map_err(|_| {
Error::Internal("background reorder scheduler is closed".into())
})?
}
};
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, source_docs) = {
let st = self.state.lock().await;
let Some(source_meta) = st.metadata.segment_metas.get(seg_id) else {
log::info!(
"[optimizer] segment {} no longer in metadata (merged away), skipping reorder",
seg_id
);
self.clear_reorder_retry(seg_id);
return Ok(false);
};
match self.active_operations.try_register(all_ids) {
Some(guard) => (guard, source_meta.num_docs),
None if !self.active_operations.is_accepting() => {
return Err(Error::IndexClosed);
}
None => {
log::debug!("[optimizer] segment {} in active merge, skipping", seg_id);
return Ok(false);
}
}
};
if let Err(error) = self.validate_completed_segment(seg_id, source_docs).await {
if is_deterministic_source_error(&error) {
self.quarantine_segment(seg_id, &error);
} else if !matches!(&error, Error::IndexClosed) {
self.pause_reorder_retries(seg_id, &error);
}
return Err(error);
}
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) => {
self.delete_output_if_unregistered(output_id, "reorder failure")
.await;
output_cleanup.disarm();
if is_deterministic_source_error(&e) {
self.quarantine_segment(seg_id, &e);
} else if !matches!(&e, Error::IndexClosed) {
self.pause_reorder_retries(seg_id, &e);
}
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();
if !matches!(&e, Error::IndexClosed) {
self.pause_reorder_retries(seg_id, &e);
}
return Err(e);
}
output_cleanup.disarm();
self.clear_reorder_retry(seg_id);
Ok(true)
}
pub async fn cleanup_orphan_segments(&self) -> Result<usize> {
let mut orphan_files: HashMap<String, Vec<std::path::PathBuf>> = HashMap::new();
if let Ok(entries) = self.directory.list_files(std::path::Path::new("")).await {
for entry in entries {
let Some(filename) = entry.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(rest) = filename.strip_prefix("seg_") else {
continue;
};
let Some(hex_id) = rest.get(..32) else {
continue;
};
if !hex_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
continue;
}
orphan_files
.entry(hex_id.to_ascii_lowercase())
.or_default()
.push(entry);
}
}
let mut deleted = 0;
for (hex_id, paths) in &orphan_files {
let deletion_guard = {
let st = self.state.lock().await;
if st.metadata.has_segment(hex_id) {
continue;
}
let Some(guard) = self
.active_operations
.try_register(vec![hex_id.to_string()])
else {
continue;
};
if self.tracker.is_deletion_protected(hex_id) {
drop(guard);
continue;
}
guard
};
let results =
futures::future::join_all(paths.iter().map(|path| self.directory.delete(path)))
.await;
let removed = results.into_iter().all(|result| match result {
Ok(()) => true,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
Err(error) => {
log::warn!(
"[segment_cleanup] failed sweeping orphan segment {}: {}",
hex_id,
error,
);
false
}
});
drop(deletion_guard);
if removed {
deleted += 1;
log::info!("[segment_cleanup] swept orphan segment {}", hex_id);
}
}
Ok(deleted)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
fn lifecycle_test_manager() -> Arc<SegmentManager<crate::directories::RamDirectory>> {
let schema = crate::dsl::SchemaBuilder::default().build();
let metadata = IndexMetadata::new(schema.clone());
Arc::new(SegmentManager::new(
Arc::new(crate::directories::RamDirectory::new()),
Arc::new(schema),
metadata,
Box::new(crate::merge::NoMergePolicy),
0,
1,
Arc::new(Semaphore::new(1)),
None,
1024,
Arc::new(Semaphore::new(1)),
None,
))
}
#[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_active_operation_guard_releases_ownership() {
let active = Arc::new(ActiveSegmentOperations::new());
{
let _guard = active.try_register(vec!["a".into(), "b".into()]).unwrap();
let snap = active.snapshot();
assert!(snap.contains("a"));
assert!(snap.contains("b"));
}
assert!(active.snapshot().is_empty());
}
#[test]
fn test_non_overlapping_operations_can_run_concurrently() {
let active = Arc::new(ActiveSegmentOperations::new());
let first = active.try_register(vec!["a".into(), "b".into()]).unwrap();
let _second = active.try_register(vec!["c".into(), "d".into()]).unwrap();
let snap = active.snapshot();
assert_eq!(snap.len(), 4);
drop(first);
let snap = active.snapshot();
assert_eq!(snap.len(), 2);
assert!(snap.contains("c"));
assert!(snap.contains("d"));
}
#[test]
fn test_overlapping_operation_is_rejected_until_release() {
let active = Arc::new(ActiveSegmentOperations::new());
let first = active.try_register(vec!["a".into(), "b".into()]).unwrap();
assert!(active.try_register(vec!["b".into(), "c".into()]).is_none());
drop(first);
assert!(active.try_register(vec!["b".into(), "c".into()]).is_some());
}
#[test]
fn test_active_operation_snapshot() {
let active = Arc::new(ActiveSegmentOperations::new());
let _guard = active.try_register(vec!["x".into(), "y".into()]).unwrap();
let snap = active.snapshot();
assert!(snap.contains("x"));
assert!(snap.contains("y"));
assert!(!snap.contains("z"));
}
#[tokio::test]
async fn shutdown_rejects_new_work_and_waits_for_existing_guard() {
let active = Arc::new(ActiveSegmentOperations::new());
let guard = active.try_register(vec!["live".into()]).unwrap();
active.stop_accepting();
assert!(active.try_register(vec!["new".into()]).is_none());
let waiter = {
let active = Arc::clone(&active);
tokio::spawn(async move { active.wait_until_idle().await })
};
tokio::task::yield_now().await;
assert!(!waiter.is_finished());
drop(guard);
tokio::time::timeout(std::time::Duration::from_secs(1), waiter)
.await
.expect("shutdown waiter missed the final guard notification")
.unwrap();
}
#[tokio::test]
async fn lifecycle_transaction_survives_request_cancellation_and_is_drained() {
let manager = lifecycle_test_manager();
let started = Arc::new(Semaphore::new(0));
let release = Arc::new(Semaphore::new(0));
let completed = Arc::new(AtomicBool::new(false));
let request = {
let manager = Arc::clone(&manager);
let started = Arc::clone(&started);
let release = Arc::clone(&release);
let completed = Arc::clone(&completed);
tokio::spawn(async move {
manager
.run_lifecycle_transaction(async move {
started.add_permits(1);
let _permit = release.acquire().await.unwrap();
completed.store(true, Ordering::Release);
Ok(())
})
.await
})
};
let _started = started.acquire().await.unwrap();
request.abort();
assert!(request.await.unwrap_err().is_cancelled());
release.add_permits(1);
manager.begin_shutdown();
tokio::time::timeout(
std::time::Duration::from_secs(1),
manager.wait_for_shutdown(),
)
.await
.expect("shutdown did not drain detached lifecycle transaction");
assert!(completed.load(Ordering::Acquire));
}
#[test]
fn merge_retry_backoff_is_exponential_and_capped() {
assert_eq!(merge_retry_delay(1), std::time::Duration::from_secs(30));
assert_eq!(merge_retry_delay(2), std::time::Duration::from_secs(60));
assert_eq!(merge_retry_delay(3), std::time::Duration::from_secs(120));
assert_eq!(merge_retry_delay(100), MERGE_RETRY_MAX_DELAY);
}
#[test]
fn only_deterministic_source_errors_are_quarantined() {
assert!(is_deterministic_source_error(&Error::Corruption(
"bad footer".into()
)));
assert!(is_deterministic_source_error(&Error::Io(
std::io::Error::from(std::io::ErrorKind::NotFound)
)));
assert!(!is_deterministic_source_error(&Error::Io(
std::io::Error::from(std::io::ErrorKind::TimedOut)
)));
assert!(!is_deterministic_source_error(&Error::Io(
std::io::Error::from(std::io::ErrorKind::PermissionDenied)
)));
}
#[test]
fn transient_reorder_failure_is_backed_off_until_cleared() {
let manager = lifecycle_test_manager();
manager.pause_reorder_retries("source", &Error::Internal("transient".into()));
assert!(manager.paused_reorder_segments().contains("source"));
manager.clear_reorder_retry("source");
assert!(!manager.paused_reorder_segments().contains("source"));
}
}