use std::{
collections::{HashMap, HashSet},
sync::{Arc, Mutex, atomic::AtomicU64, mpsc},
thread,
time::{Duration, Instant},
};
use super::{
CancellationToken, LoadMonitor, RepackContext, RepackError, RepackHandle, RepackOperation,
RepackPolicy, RepackReason, RepackReport, RepackResourceLimits, RepackSchedule, types::NoLoad,
};
#[derive(Default)]
struct SchedulerState {
running: HashSet<String>,
next_attempt: HashMap<String, Instant>,
failure_count: HashMap<String, u32>,
}
#[derive(Clone)]
pub struct RepackScheduler {
policy: RepackPolicy,
limits: RepackResourceLimits,
load: Arc<dyn LoadMonitor>,
state: Arc<Mutex<SchedulerState>>,
success_backoff: Duration,
failure_backoff: Duration,
maximum_failure_backoff: Duration,
}
impl RepackScheduler {
pub fn new(policy: RepackPolicy, limits: RepackResourceLimits) -> Self {
Self {
policy,
limits,
load: Arc::new(NoLoad),
state: Arc::new(Mutex::new(SchedulerState::default())),
success_backoff: Duration::from_secs(30 * 60),
failure_backoff: Duration::from_secs(30),
maximum_failure_backoff: Duration::from_secs(30 * 60),
}
}
pub fn with_load_monitor(mut self, load: Arc<dyn LoadMonitor>) -> Self {
self.load = load;
self
}
pub fn with_backoff(
mut self,
success: Duration,
initial_failure: Duration,
maximum_failure: Duration,
) -> Self {
self.success_backoff = success;
self.failure_backoff = initial_failure;
self.maximum_failure_backoff = maximum_failure.max(initial_failure);
self
}
pub fn schedule_if_needed(
&self,
operation: Arc<dyn RepackOperation>,
) -> Result<RepackSchedule, RepackError> {
let inventory = operation.inspect()?;
let Some(reason) = self.policy.evaluate(inventory) else {
return Ok(RepackSchedule::NotNeeded(inventory));
};
self.start(operation, reason, false, CancellationToken::default())
}
pub fn repack_now(
&self,
operation: Arc<dyn RepackOperation>,
) -> Result<RepackSchedule, RepackError> {
self.start(
operation,
RepackReason::Manual,
true,
CancellationToken::default(),
)
}
pub fn repack_now_with_token(
&self,
operation: Arc<dyn RepackOperation>,
cancellation: CancellationToken,
) -> Result<RepackSchedule, RepackError> {
self.start(operation, RepackReason::Manual, true, cancellation)
}
fn start(
&self,
operation: Arc<dyn RepackOperation>,
reason: RepackReason,
bypass_backoff: bool,
cancellation: CancellationToken,
) -> Result<RepackSchedule, RepackError> {
let key = operation.key();
{
let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
if state.running.contains(&key)
|| state.running.len() >= self.limits.max_concurrent_operations.get()
{
return Ok(RepackSchedule::Busy);
}
if !bypass_backoff
&& let Some(next) = state.next_attempt.get(&key)
&& *next > Instant::now()
{
return Ok(RepackSchedule::BackingOff {
remaining: next.saturating_duration_since(Instant::now()),
});
}
state.running.insert(key.clone());
}
let state = Arc::clone(&self.state);
let load = Arc::clone(&self.load);
let limits = self.limits;
let success_backoff = self.success_backoff;
let failure_backoff = self.failure_backoff;
let maximum_failure_backoff = self.maximum_failure_backoff;
let worker_cancellation = cancellation.clone();
let (sender, receiver) = mpsc::channel();
let worker_state = Arc::clone(&state);
let worker_key = key.clone();
let spawn = thread::Builder::new()
.name(format!("heddle-repack-{key}"))
.spawn(move || {
let started = Instant::now();
let context = RepackContext {
cancellation: worker_cancellation,
load,
limits,
started,
accounted_io: AtomicU64::new(0),
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
operation.run(&context)
}))
.map_err(|_| RepackError::WorkerPanicked)
.and_then(|result| result)
.map(|outcome| RepackReport {
reason,
objects_repacked: outcome.objects_repacked,
bytes_repacked: outcome.bytes_repacked,
duration: started.elapsed(),
bytes_reclaimed: outcome.bytes_reclaimed,
});
finish_attempt(
&worker_state,
&worker_key,
&result,
success_backoff,
failure_backoff,
maximum_failure_backoff,
);
if let Ok(report) = result {
tracing::info!(
operation = %worker_key,
objects_repacked = report.objects_repacked,
bytes_repacked = report.bytes_repacked,
duration_ms = report.duration.as_millis(),
bytes_reclaimed = report.bytes_reclaimed,
"background repack completed"
);
}
let _ = sender.send(result);
});
let worker = match spawn {
Ok(worker) => worker,
Err(error) => {
state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.running
.remove(&key);
return Err(RepackError::operation(error));
}
};
Ok(RepackSchedule::Started(RepackHandle {
cancellation,
result: receiver,
worker,
}))
}
}
fn finish_attempt(
state: &Mutex<SchedulerState>,
key: &str,
result: &Result<RepackReport, RepackError>,
success_backoff: Duration,
failure_backoff: Duration,
maximum_failure_backoff: Duration,
) {
let mut state = state.lock().unwrap_or_else(|error| error.into_inner());
state.running.remove(key);
let delay = if result.is_ok() {
state.failure_count.remove(key);
success_backoff
} else {
let failures = state.failure_count.entry(key.to_string()).or_default();
*failures = failures.saturating_add(1);
failure_backoff
.checked_mul(2u32.saturating_pow(failures.saturating_sub(1)))
.unwrap_or(maximum_failure_backoff)
.min(maximum_failure_backoff)
};
state
.next_attempt
.insert(key.to_string(), Instant::now() + delay);
}