use std::collections::VecDeque;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use foundation_nostd::comp::condvar_comp::{CondVar, Mutex, MutexGuard};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use crate::synca::WaitGroup;
use crate::valtron::GenericResult;
pub type BackgroundJob = Box<dyn FnOnce() + Send + 'static>;
struct JobQueue {
state: Mutex<QueueState>,
available: CondVar,
}
struct QueueState {
jobs: VecDeque<BackgroundJob>,
closed: bool,
}
impl JobQueue {
fn new() -> Self {
Self {
state: Mutex::new(QueueState {
jobs: VecDeque::new(),
closed: false,
}),
available: CondVar::new(),
}
}
fn lock(&self) -> MutexGuard<'_, QueueState> {
self.state.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn push(&self, job: BackgroundJob) -> Result<(), BackgroundJob> {
{
let mut state = self.lock();
if state.closed {
return Err(job);
}
state.jobs.push_back(job);
}
self.available.notify_one();
Ok(())
}
fn pop_blocking(&self) -> Option<BackgroundJob> {
let mut state = self.lock();
loop {
if let Some(job) = state.jobs.pop_front() {
return Some(job);
}
if state.closed {
return None;
}
state = self
.available
.wait(state)
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
}
fn close(&self) {
self.lock().closed = true;
self.available.notify_all();
}
fn drain(&self) -> Vec<BackgroundJob> {
self.lock().jobs.drain(..).collect()
}
}
fn run_job(job: BackgroundJob, runner: &str) {
if let Err(panic_info) = std::panic::catch_unwind(AssertUnwindSafe(job)) {
tracing::error!("Background job panicked on {runner}: {panic_info:?}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BackgroundJobConfig {
num_threads: usize,
shutdown_timeout: Duration,
}
impl BackgroundJobConfig {
pub const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
#[must_use]
pub fn new(num_threads: usize) -> Self {
Self {
num_threads,
shutdown_timeout: Self::DEFAULT_SHUTDOWN_TIMEOUT,
}
}
#[must_use]
pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
self.shutdown_timeout = timeout;
self
}
#[must_use]
pub fn num_threads(&self) -> usize {
self.num_threads
}
#[must_use]
pub fn shutdown_timeout(&self) -> Duration {
self.shutdown_timeout
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum ShutdownOutcome {
Completed,
TimedOut {
abandoned_jobs: usize,
busy_workers: usize,
},
}
pub struct BackgroundJobRegistry {
queue: Arc<JobQueue>,
waitgroup: WaitGroup,
thread_handles: Mutex<Vec<JoinHandle<()>>>,
num_threads: usize,
shutdown_timeout: Duration,
}
impl BackgroundJobRegistry {
#[must_use]
pub fn new(num_threads: usize) -> Arc<Self> {
Self::with_config(BackgroundJobConfig::new(num_threads))
}
#[must_use]
pub fn with_config(config: BackgroundJobConfig) -> Arc<Self> {
let num_threads = config.num_threads;
assert!(
num_threads >= 1,
"BackgroundJobRegistry requires at least 1 thread"
);
let registry = Arc::new(Self {
queue: Arc::new(JobQueue::new()),
waitgroup: WaitGroup::new(),
thread_handles: Mutex::new(Vec::with_capacity(num_threads)),
num_threads,
shutdown_timeout: config.shutdown_timeout,
});
let mut handles = Vec::with_capacity(num_threads);
for i in 0..num_threads {
let queue = registry.queue.clone();
let wg = registry.waitgroup.clone();
wg.add(1);
let handle = thread::Builder::new()
.name(format!("bg-worker-{i}"))
.spawn(move || {
let _guard = wg.guard();
tracing::debug!("Background worker {i} started");
Self::worker_loop(&queue, i);
tracing::debug!("Background worker {i} exiting");
})
.expect("Failed to spawn background worker thread");
handles.push(handle);
}
*registry
.thread_handles
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = handles;
registry
}
fn worker_loop(queue: &JobQueue, worker_id: usize) {
let runner = format!("worker {worker_id}");
while let Some(job) = queue.pop_blocking() {
run_job(job, &runner);
}
}
pub fn submit(&self, job: impl FnOnce() + Send + 'static) -> GenericResult<()> {
self.queue
.push(Box::new(job))
.map_err(|_| "BackgroundJobRegistry: queue closed, cannot submit job".into())
}
pub fn shutdown(&self) -> ShutdownOutcome {
let deadline = Instant::now() + self.shutdown_timeout;
self.queue.close();
let drained_in_time = self.waitgroup.wait_timeout(self.shutdown_timeout);
let handles: Vec<JoinHandle<()>> = self
.thread_handles
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.drain(..)
.collect();
let mut busy_workers = 0;
for handle in handles {
if !drained_in_time && !handle.is_finished() {
busy_workers += 1;
continue;
}
let name = handle.thread().name().unwrap_or("bg-worker").to_string();
if let Err(panic_info) = handle.join() {
tracing::error!("Background worker {name} panicked outside a job: {panic_info:?}");
}
}
let mut abandoned_jobs = 0;
let leftover = self.queue.drain();
if !leftover.is_empty() && drained_in_time {
tracing::warn!(
"BackgroundJobRegistry::shutdown: {} queued job(s) outlived the workers; running them on the shutdown caller",
leftover.len()
);
}
for job in leftover {
if drained_in_time && Instant::now() < deadline {
run_job(job, "the shutdown caller");
} else {
abandoned_jobs += 1;
}
}
if busy_workers == 0 && abandoned_jobs == 0 {
return ShutdownOutcome::Completed;
}
tracing::error!(
"BackgroundJobRegistry::shutdown: timed out after {:?}; dropped {abandoned_jobs} unstarted job(s), detached {busy_workers} worker(s) still inside a job",
self.shutdown_timeout
);
ShutdownOutcome::TimedOut {
abandoned_jobs,
busy_workers,
}
}
#[must_use]
pub fn num_threads(&self) -> usize {
self.num_threads
}
}
impl Drop for BackgroundJobRegistry {
fn drop(&mut self) {
self.queue.close();
}
}
#[must_use]
pub fn split_thread_count(total: usize) -> (usize, usize) {
assert!(total >= 3, "split_thread_count requires at least 3 threads");
let task_threads = 2.max(total / 2);
let bg_threads = total;
(task_threads, bg_threads)
}