use std::collections::VecDeque;
use std::panic::{self, AssertUnwindSafe};
use std::sync::mpsc::{self, RecvError};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use super::DatabaseError;
const MAX_ADMITTED_BATCHES: usize = 4;
type Job = Box<dyn FnOnce() + Send + 'static>;
struct QueueState {
jobs: VecDeque<Job>,
shutting_down: bool,
running: usize,
}
struct AdmissionState {
admitted: usize,
shutting_down: bool,
}
struct Shared {
queue: Mutex<QueueState>,
queue_ready: Condvar,
admission: Mutex<AdmissionState>,
admission_free: Condvar,
max_admitted_batches: usize,
}
impl Shared {
fn new(max_admitted_batches: usize) -> Self {
Self {
queue: Mutex::new(QueueState {
jobs: VecDeque::new(),
shutting_down: false,
running: 0,
}),
queue_ready: Condvar::new(),
admission: Mutex::new(AdmissionState {
admitted: 0,
shutting_down: false,
}),
admission_free: Condvar::new(),
max_admitted_batches,
}
}
fn begin_shutdown(&self) {
if let Ok(mut admission) = self.admission.lock() {
admission.shutting_down = true;
}
self.admission_free.notify_all();
if let Ok(mut queue) = self.queue.lock() {
queue.shutting_down = true;
}
self.queue_ready.notify_all();
}
}
struct BatchPermit {
shared: Arc<Shared>,
}
impl Drop for BatchPermit {
fn drop(&mut self) {
if let Ok(mut admission) = self.shared.admission.lock() {
admission.admitted = admission.admitted.saturating_sub(1);
}
self.shared.admission_free.notify_one();
}
}
pub struct Executor {
shared: Arc<Shared>,
workers: Vec<JoinHandle<()>>,
#[cfg(test)]
dispatched_jobs: std::sync::atomic::AtomicUsize,
}
impl Executor {
pub(crate) fn start(worker_count: usize) -> Result<Self, DatabaseError> {
Self::start_with_admission(worker_count, MAX_ADMITTED_BATCHES)
}
#[cfg(test)]
pub(crate) fn start_with_admission_for_test(
worker_count: usize,
max_admitted_batches: usize,
) -> Result<Self, DatabaseError> {
Self::start_with_admission(worker_count, max_admitted_batches)
}
fn start_with_admission(
worker_count: usize,
max_admitted_batches: usize,
) -> Result<Self, DatabaseError> {
let shared = Arc::new(Shared::new(max_admitted_batches));
let mut workers = Vec::with_capacity(worker_count);
for index in 0..worker_count {
let worker_shared = Arc::clone(&shared);
let spawned = thread::Builder::new()
.name(format!("haematite-exec-{index}"))
.spawn(move || worker_loop(&worker_shared));
match spawned {
Ok(handle) => workers.push(handle),
Err(error) => {
shared.begin_shutdown();
for handle in workers {
drop(handle.join());
}
return Err(DatabaseError::ExecutorThreadsInvalid(format!(
"failed to spawn executor worker {index}: {error}"
)));
}
}
}
Ok(Self {
shared,
workers,
#[cfg(test)]
dispatched_jobs: std::sync::atomic::AtomicUsize::new(0),
})
}
fn admit(&self) -> Result<BatchPermit, DatabaseError> {
let mut admission = self
.shared
.admission
.lock()
.map_err(|_| DatabaseError::ExecutorShutdown)?;
loop {
if admission.shutting_down {
return Err(DatabaseError::ExecutorShutdown);
}
if admission.admitted < self.shared.max_admitted_batches {
admission.admitted += 1;
return Ok(BatchPermit {
shared: Arc::clone(&self.shared),
});
}
admission = self
.shared
.admission_free
.wait(admission)
.map_err(|_| DatabaseError::ExecutorShutdown)?;
}
}
pub(crate) fn submit<Item, Output, Materialise, Work>(
&self,
materialise: Materialise,
work: Work,
) -> Result<Vec<(usize, Output)>, DatabaseError>
where
Item: Send + 'static,
Output: Send + 'static,
Materialise: FnOnce() -> Result<Vec<Item>, DatabaseError>,
Work: Fn(Item) -> Output + Send + Sync + 'static,
{
let permit = self.admit()?;
let outcome = materialise().and_then(|items| self.run_batch(items, work));
drop(permit);
outcome
}
fn run_batch<Item, Output, Work>(
&self,
items: Vec<Item>,
work: Work,
) -> Result<Vec<(usize, Output)>, DatabaseError>
where
Item: Send + 'static,
Output: Send + 'static,
Work: Fn(Item) -> Output + Send + Sync + 'static,
{
let job_count = items.len();
if job_count == 0 {
return Ok(Vec::new());
}
#[cfg(test)]
self.dispatched_jobs
.fetch_add(job_count, std::sync::atomic::Ordering::Relaxed);
let work = Arc::new(work);
let (results_tx, results_rx) = mpsc::channel::<(usize, Result<Output, ()>)>();
{
let mut queue = self
.shared
.queue
.lock()
.map_err(|_| DatabaseError::ShardError("executor queue poisoned".to_owned()))?;
for (index, item) in items.into_iter().enumerate() {
let work = Arc::clone(&work);
let results_tx = results_tx.clone();
queue.jobs.push_back(Box::new(move || {
let outcome = panic::catch_unwind(AssertUnwindSafe(|| work(item)));
drop(results_tx.send((index, outcome.map_err(drop))));
}));
}
}
drop(results_tx);
self.shared.queue_ready.notify_all();
collect_batch(&results_rx, job_count)
}
#[cfg(test)]
pub(crate) fn admitted_batches(&self) -> usize {
self.shared
.admission
.lock()
.map(|admission| admission.admitted)
.unwrap_or(0)
}
#[cfg(test)]
pub(crate) fn queued_and_running(&self) -> usize {
self.shared
.queue
.lock()
.map(|queue| queue.jobs.len() + queue.running)
.unwrap_or(0)
}
#[cfg(test)]
pub(crate) const fn worker_count(&self) -> usize {
self.workers.len()
}
#[cfg(test)]
pub(crate) fn max_admitted_batches(&self) -> usize {
self.shared.max_admitted_batches
}
#[cfg(test)]
pub(crate) fn dispatched_jobs(&self) -> usize {
self.dispatched_jobs
.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(test)]
pub(crate) fn trigger_shutdown(&self) {
self.shared.begin_shutdown();
}
pub(crate) fn shutdown(&mut self) {
self.shared.begin_shutdown();
for handle in self.workers.drain(..) {
drop(handle.join());
}
}
}
impl Drop for Executor {
fn drop(&mut self) {
self.shutdown();
}
}
fn collect_batch<Output>(
results_rx: &mpsc::Receiver<(usize, Result<Output, ()>)>,
job_count: usize,
) -> Result<Vec<(usize, Output)>, DatabaseError> {
let mut slots: Vec<Option<Output>> = (0..job_count).map(|_| None).collect();
let mut panicked = false;
for _ in 0..job_count {
match results_rx.recv() {
Ok((index, Ok(output))) => {
if let Some(slot) = slots.get_mut(index) {
*slot = Some(output);
}
}
Ok((_, Err(()))) => panicked = true,
Err(RecvError) => {
return Err(DatabaseError::ShardError(
"executor worker dropped a batch result before replying".to_owned(),
));
}
}
}
if panicked {
return Err(DatabaseError::ShardError(
"parallel worker thread panicked".to_owned(),
));
}
Ok(slots
.into_iter()
.enumerate()
.filter_map(|(index, output)| output.map(|output| (index, output)))
.collect())
}
fn worker_loop(shared: &Arc<Shared>) {
loop {
let job = {
let Ok(mut queue) = shared.queue.lock() else {
return;
};
loop {
if let Some(job) = queue.jobs.pop_front() {
queue.running += 1;
break job;
}
if queue.shutting_down {
return;
}
queue = match shared.queue_ready.wait(queue) {
Ok(queue) => queue,
Err(_) => return,
};
}
};
job();
if let Ok(mut queue) = shared.queue.lock() {
queue.running = queue.running.saturating_sub(1);
}
}
}
pub(super) fn resolve_worker_count(
configured: Option<usize>,
shard_count: usize,
) -> Result<usize, DatabaseError> {
match configured {
Some(0) => Err(DatabaseError::ExecutorThreadsInvalid(
"executor_threads = 0 is not a valid worker count; set a positive integer or omit \
the field for the signed default min(shard_count, available_parallelism)"
.to_owned(),
)),
Some(threads) => Ok(threads),
None => {
let parallelism = thread::available_parallelism().map_err(|error| {
DatabaseError::ExecutorThreadsInvalid(format!(
"available_parallelism could not be determined ({error}); set executor_threads \
explicitly to choose the worker count"
))
})?;
Ok(shard_count.min(parallelism.get()).max(1))
}
}
}
#[cfg(test)]
#[path = "executor_tests.rs"]
mod tests;