use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use concurrent_queue::ConcurrentQueue;
use crate::synca::{OnSignal, WaitGroup};
use crate::valtron::GenericResult;
pub type BackgroundJob = Box<dyn FnOnce() + Send + 'static>;
pub const DEFAULT_BG_YIELD_DURATION: Duration = Duration::from_millis(50);
pub struct BackgroundJobRegistry {
job_queue: Arc<ConcurrentQueue<BackgroundJob>>,
kill_signal: Arc<OnSignal>,
waitgroup: WaitGroup,
thread_handles: std::sync::Mutex<Vec<JoinHandle<()>>>,
num_threads: usize,
}
impl BackgroundJobRegistry {
pub fn new(
num_threads: usize,
kill_signal: Arc<OnSignal>,
yield_duration: Duration,
) -> Arc<Self> {
assert!(
num_threads >= 1,
"BackgroundJobRegistry requires at least 1 thread"
);
let job_queue: Arc<ConcurrentQueue<BackgroundJob>> = Arc::new(ConcurrentQueue::unbounded());
let waitgroup = WaitGroup::new();
let registry = Arc::new(Self {
job_queue,
kill_signal,
waitgroup,
thread_handles: std::sync::Mutex::new(Vec::with_capacity(num_threads)),
num_threads,
});
for i in 0..num_threads {
let queue = registry.job_queue.clone();
let kill = registry.kill_signal.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, &kill, yield_duration, i);
tracing::debug!("Background worker {i} exiting");
})
.expect("Failed to spawn background worker thread");
registry.thread_handles.lock().unwrap().push(handle);
}
registry
}
fn worker_loop(
queue: &ConcurrentQueue<BackgroundJob>,
kill_signal: &OnSignal,
yield_duration: Duration,
worker_id: usize,
) {
loop {
if kill_signal.probe() {
break;
}
match queue.pop() {
Ok(job) => {
let result = std::panic::catch_unwind(AssertUnwindSafe(job));
if let Err(panic_info) = result {
tracing::error!(
"Background job panicked on worker {worker_id}: {panic_info:?}"
);
}
}
Err(_) => {
thread::sleep(yield_duration);
}
}
}
}
pub fn submit(&self, job: impl FnOnce() + Send + 'static) -> GenericResult<()> {
self.job_queue
.push(Box::new(job))
.map_err(|_| "BackgroundJobRegistry: queue closed, cannot submit job".into())
}
pub fn shutdown(&self) {
self.job_queue.close();
self.kill_signal.turn_on();
self.waitgroup.wait();
let mut handles = self.thread_handles.lock().unwrap();
for handle in handles.drain(..) {
let _ = handle.join();
}
}
#[must_use]
pub fn num_threads(&self) -> usize {
self.num_threads
}
}
#[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)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn test_split_thread_count() {
assert_eq!(split_thread_count(3), (2, 3));
assert_eq!(split_thread_count(4), (2, 4));
assert_eq!(split_thread_count(5), (2, 5));
assert_eq!(split_thread_count(6), (3, 6));
assert_eq!(split_thread_count(7), (3, 7));
assert_eq!(split_thread_count(8), (4, 8));
assert_eq!(split_thread_count(10), (5, 10));
assert_eq!(split_thread_count(12), (6, 12));
assert_eq!(split_thread_count(16), (8, 16));
}
#[test]
#[should_panic(expected = "split_thread_count requires at least 3 threads")]
#[cfg_attr(
cranelift_backend,
ignore = "cranelift does not support panic unwinding"
)]
fn test_split_thread_count_panics_below_3() {
let _ = split_thread_count(1);
let _ = split_thread_count(2);
}
#[test]
fn test_submit_and_execute_job() {
let kill_signal = Arc::new(OnSignal::new());
let registry =
BackgroundJobRegistry::new(2, kill_signal.clone(), Duration::from_millis(10));
let result = Arc::new(Mutex::new(false));
let result_clone = result.clone();
registry
.submit(move || {
*result_clone.lock().unwrap() = true;
})
.expect("submit should succeed");
thread::sleep(Duration::from_millis(100));
assert!(*result.lock().unwrap(), "job should have executed");
registry.shutdown();
}
#[test]
#[cfg_attr(
cranelift_backend,
ignore = "cranelift does not support panic unwinding"
)]
fn test_panic_recovery() {
let kill_signal = Arc::new(OnSignal::new());
let registry =
BackgroundJobRegistry::new(1, kill_signal.clone(), Duration::from_millis(10));
registry
.submit(|| {
panic!("intentional test panic");
})
.expect("submit should succeed");
thread::sleep(Duration::from_millis(100));
let result = Arc::new(Mutex::new(false));
let result_clone = result.clone();
registry
.submit(move || {
*result_clone.lock().unwrap() = true;
})
.expect("submit should succeed after panic");
thread::sleep(Duration::from_millis(100));
assert!(
*result.lock().unwrap(),
"worker should recover from panic and execute next job"
);
registry.shutdown();
}
#[test]
fn test_shutdown() {
let kill_signal = Arc::new(OnSignal::new());
let registry =
BackgroundJobRegistry::new(2, kill_signal.clone(), Duration::from_millis(10));
let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
for _ in 0..10 {
let c = counter.clone();
registry
.submit(move || {
c.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
.expect("submit should succeed");
}
thread::sleep(Duration::from_millis(200));
registry.shutdown();
assert_eq!(
counter.load(std::sync::atomic::Ordering::SeqCst),
10,
"all 10 jobs should have executed"
);
}
#[test]
fn test_submit_after_shutdown_fails() {
let kill_signal = Arc::new(OnSignal::new());
let registry =
BackgroundJobRegistry::new(1, kill_signal.clone(), Duration::from_millis(10));
registry.shutdown();
let result = registry.submit(|| {});
assert!(result.is_err(), "submit after shutdown should fail");
}
}