1use hawdb_core::{
16 HawDBError, Result as HawDBResult, RuntimeCancellationReason, RuntimeTaskContext,
17};
18use std::collections::BTreeMap;
19use std::error::Error;
20use std::fmt::{self, Display, Formatter};
21use std::num::NonZeroUsize;
22use std::panic::{catch_unwind, AssertUnwindSafe};
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::mpsc::{self, RecvTimeoutError};
25use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock};
26use std::time::Duration;
27
28const ORDERED_STREAM_CHECK_INTERVAL: Duration = Duration::from_millis(10);
29const SHARED_EXECUTOR_WORKER_LIMIT: usize = 16;
30
31#[derive(Clone)]
32pub struct SharedExecutorPool {
33 inner: Arc<rayon::ThreadPool>,
34 worker_count: NonZeroUsize,
35}
36
37impl fmt::Debug for SharedExecutorPool {
38 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
39 formatter
40 .debug_struct("SharedExecutorPool")
41 .field("worker_count", &self.worker_count)
42 .finish_non_exhaustive()
43 }
44}
45
46impl SharedExecutorPool {
47 pub fn new(worker_count: NonZeroUsize) -> Result<Self, SharedExecutorPoolError> {
48 let inner = rayon::ThreadPoolBuilder::new()
49 .num_threads(worker_count.get())
50 .thread_name(|index| format!("hawdb-executor-{index}"))
51 .build()
52 .map_err(|error| SharedExecutorPoolError(error.to_string()))?;
53 Ok(Self {
54 inner: Arc::new(inner),
55 worker_count,
56 })
57 }
58
59 pub fn shared_default() -> Result<Self, SharedExecutorPoolError> {
60 Self::shared_bounded(default_shared_worker_count())
61 }
62
63 pub fn shared_bounded(worker_limit: NonZeroUsize) -> Result<Self, SharedExecutorPoolError> {
64 static SHARED: OnceLock<
65 Mutex<BTreeMap<usize, Result<SharedExecutorPool, SharedExecutorPoolError>>>,
66 > = OnceLock::new();
67 let worker_count = default_shared_worker_count().min(worker_limit);
68 let mut shared = SHARED
69 .get_or_init(|| Mutex::new(BTreeMap::new()))
70 .lock()
71 .unwrap_or_else(|poisoned| poisoned.into_inner());
72 shared
73 .entry(worker_count.get())
74 .or_insert_with(|| Self::new(worker_count))
75 .clone()
76 }
77
78 pub fn worker_count(&self) -> usize {
79 self.worker_count.get()
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SharedExecutorPoolError(String);
85
86impl Display for SharedExecutorPoolError {
87 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
88 write!(
89 formatter,
90 "failed to build shared executor pool: {}",
91 self.0
92 )
93 }
94}
95
96impl Error for SharedExecutorPoolError {}
97
98fn default_shared_worker_count() -> NonZeroUsize {
99 std::thread::available_parallelism()
100 .unwrap_or(NonZeroUsize::MIN)
101 .min(
102 NonZeroUsize::new(SHARED_EXECUTOR_WORKER_LIMIT)
103 .expect("shared worker limit is non-zero"),
104 )
105}
106
107#[derive(Debug, Clone)]
108pub struct BoundedExecutor {
109 max_parallelism: NonZeroUsize,
110 pool: Result<SharedExecutorPool, SharedExecutorPoolError>,
111}
112
113impl BoundedExecutor {
114 pub fn new(max_parallelism: NonZeroUsize) -> Self {
115 Self {
116 max_parallelism,
117 pool: SharedExecutorPool::shared_bounded(max_parallelism),
118 }
119 }
120
121 pub fn with_pool(max_parallelism: NonZeroUsize, pool: SharedExecutorPool) -> Self {
122 Self {
123 max_parallelism,
124 pool: Ok(pool),
125 }
126 }
127
128 pub fn max_parallelism(&self) -> usize {
129 self.max_parallelism.get()
130 }
131
132 pub fn is_degraded(&self) -> bool {
133 self.pool.is_err()
134 }
135
136 pub fn pool_error(&self) -> Option<&SharedExecutorPoolError> {
137 self.pool.as_ref().err()
138 }
139
140 pub fn map_ordered<T, R, F>(self, inputs: &[T], operation: F) -> Vec<R>
144 where
145 T: Sync,
146 R: Send,
147 F: Fn(&T) -> R + Sync,
148 {
149 if inputs.is_empty() {
150 return Vec::new();
151 }
152 let Ok(pool) = &self.pool else {
153 return inputs.iter().map(operation).collect();
154 };
155
156 let worker_count = self.max_parallelism().min(inputs.len());
157 let next = AtomicUsize::new(0);
158 let outputs = Mutex::new(
159 std::iter::repeat_with(|| None)
160 .take(inputs.len())
161 .collect::<Vec<Option<R>>>(),
162 );
163
164 let run = || {
165 rayon::scope(|scope| {
166 for _ in 0..worker_count {
167 scope.spawn(|_| loop {
168 let index = next.fetch_add(1, Ordering::Relaxed);
169 let Some(input) = inputs.get(index) else {
170 break;
171 };
172 let output = operation(input);
173 outputs
174 .lock()
175 .expect("bounded executor output lock should not be poisoned")[index] =
176 Some(output);
177 });
178 }
179 });
180 };
181 pool.inner.install(run);
182
183 outputs
184 .into_inner()
185 .expect("bounded executor output lock should not be poisoned")
186 .into_iter()
187 .map(|output| output.expect("each bounded executor input must produce one output"))
188 .collect()
189 }
190
191 pub fn map_ordered_with_context<T, R, F>(
194 self,
195 inputs: &[T],
196 context: &RuntimeTaskContext,
197 operation: F,
198 ) -> Result<Vec<R>, RuntimeCancellationReason>
199 where
200 T: Sync,
201 R: Send,
202 F: Fn(&T) -> R + Sync,
203 {
204 context.checkpoint()?;
205 if inputs.is_empty() {
206 return Ok(Vec::new());
207 }
208 let Ok(pool) = &self.pool else {
209 let output = inputs
210 .iter()
211 .map(|input| {
212 context.checkpoint()?;
213 Ok(operation(input))
214 })
215 .collect::<Result<Vec<_>, RuntimeCancellationReason>>()?;
216 context.checkpoint()?;
217 return Ok(output);
218 };
219
220 let worker_count = self.max_parallelism().min(inputs.len());
221 let next = AtomicUsize::new(0);
222 let stopped = Mutex::new(None);
223 let outputs = Mutex::new(
224 std::iter::repeat_with(|| None)
225 .take(inputs.len())
226 .collect::<Vec<Option<R>>>(),
227 );
228
229 let run = || {
230 rayon::scope(|scope| {
231 for _ in 0..worker_count {
232 scope.spawn(|_| loop {
233 if let Err(reason) = context.checkpoint() {
234 let mut stopped = stopped.lock().expect(
235 "bounded executor cancellation lock should not be poisoned",
236 );
237 stopped.get_or_insert(reason);
238 break;
239 }
240 let index = next.fetch_add(1, Ordering::Relaxed);
241 let Some(input) = inputs.get(index) else {
242 break;
243 };
244 let output = operation(input);
245 outputs
246 .lock()
247 .expect("bounded executor output lock should not be poisoned")[index] =
248 Some(output);
249 });
250 }
251 });
252 };
253 pool.inner.install(run);
254
255 if let Some(reason) = *stopped
256 .lock()
257 .expect("bounded executor cancellation lock should not be poisoned")
258 {
259 return Err(reason);
260 }
261 context.checkpoint()?;
262 Ok(outputs
263 .into_inner()
264 .expect("bounded executor output lock should not be poisoned")
265 .into_iter()
266 .map(|output| output.expect("each bounded executor input must produce one output"))
267 .collect())
268 }
269
270 pub(crate) fn try_for_each_index_ordered<R, F, C>(
271 &self,
272 input_count: usize,
273 context: Option<&RuntimeTaskContext>,
274 operation: F,
275 mut consume: C,
276 ) -> HawDBResult<BoundedOrderedStreamReport>
277 where
278 R: Send,
279 F: Fn(usize) -> HawDBResult<R> + Sync,
280 C: FnMut(usize, R) -> HawDBResult<BoundedOrderedStreamControl>,
281 {
282 runtime_checkpoint(context)?;
283 if input_count == 0 {
284 return Ok(BoundedOrderedStreamReport::default());
285 }
286
287 let Ok(pool) = &self.pool else {
288 return run_sequential_index_stream(input_count, context, &operation, &mut consume);
289 };
290 let worker_count = self
291 .max_parallelism()
292 .min(pool.worker_count())
293 .min(input_count);
294 if worker_count == 1 {
295 return run_sequential_index_stream(input_count, context, &operation, &mut consume);
296 }
297
298 let window = OrderedWorkWindow {
299 state: Mutex::new(OrderedWorkState {
300 next_index: 0,
301 consumed_prefix: 0,
302 stopped: false,
303 }),
304 changed: Condvar::new(),
305 };
306 let (sender, receiver) = mpsc::sync_channel(worker_count);
307 let mut reorder = BTreeMap::new();
308 let mut report = BoundedOrderedStreamReport::default();
309
310 let result = pool.inner.in_place_scope(|scope| {
311 for _ in 0..worker_count {
312 let sender = sender.clone();
313 let window = &window;
314 let operation = &operation;
315 scope.spawn(move |_| {
316 while let Some(index) =
317 claim_ordered_index(window, input_count, worker_count, context)
318 {
319 if let Err(reason) = context_checkpoint(context) {
320 let _ = sender.send(OrderedWorkerMessage::Stopped(reason));
321 break;
322 }
323 let output = catch_unwind(AssertUnwindSafe(|| operation(index)))
324 .unwrap_or_else(|_| {
325 Err(HawDBError::Execution(format!(
326 "bounded executor worker panicked at input index {index}"
327 )))
328 });
329 if sender
330 .send(OrderedWorkerMessage::Output(index, output))
331 .is_err()
332 {
333 break;
334 }
335 }
336 });
337 }
338 drop(sender);
339
340 let mut next_expected = 0usize;
341 let result = 'receive: loop {
342 if let Err(reason) = context_checkpoint(context) {
343 break Err(runtime_stopped_error(reason));
344 }
345 match receiver.recv_timeout(ORDERED_STREAM_CHECK_INTERVAL) {
346 Ok(OrderedWorkerMessage::Output(index, output)) => {
347 let output = match output {
348 Ok(output) => output,
349 Err(error) => break Err(error),
350 };
351 if reorder.insert(index, output).is_some() {
352 break Err(HawDBError::Execution(format!(
353 "bounded executor produced duplicate output index {index}"
354 )));
355 }
356 report.peak_reorder_entries =
357 report.peak_reorder_entries.max(reorder.len());
358
359 while let Some(output) = reorder.remove(&next_expected) {
360 let control = match catch_unwind(AssertUnwindSafe(|| {
361 consume(next_expected, output)
362 }))
363 .unwrap_or_else(|_| {
364 Err(HawDBError::Execution(format!(
365 "bounded executor consumer panicked at input index {next_expected}"
366 )))
367 }) {
368 Ok(control) => control,
369 Err(error) => break 'receive Err(error),
370 };
371 next_expected = next_expected.saturating_add(1);
372 if control == BoundedOrderedStreamControl::Stop {
373 report.stopped_early = true;
374 break;
375 }
376 advance_ordered_prefix(&window, next_expected);
377 }
378 if report.stopped_early {
379 break Ok(report);
380 }
381 if next_expected == input_count {
382 break Ok(report);
383 }
384 }
385 Ok(OrderedWorkerMessage::Stopped(reason)) => {
386 break Err(runtime_stopped_error(reason));
387 }
388 Err(RecvTimeoutError::Timeout) => continue,
389 Err(RecvTimeoutError::Disconnected) => {
390 break Err(HawDBError::Execution(format!(
391 "bounded executor stopped after {next_expected} of {input_count} ordered outputs"
392 )));
393 }
394 }
395 };
396
397 stop_ordered_window(&window);
398 drop(receiver);
399 result
400 });
401 match result {
402 Ok(report) => {
403 runtime_checkpoint(context)?;
404 Ok(report)
405 }
406 Err(error) => Err(error),
407 }
408 }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub(crate) enum BoundedOrderedStreamControl {
413 Continue,
414 Stop,
415}
416
417#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
418pub(crate) struct BoundedOrderedStreamReport {
419 pub(crate) peak_reorder_entries: usize,
420 pub(crate) stopped_early: bool,
421}
422
423struct OrderedWorkWindow {
424 state: Mutex<OrderedWorkState>,
425 changed: Condvar,
426}
427
428struct OrderedWorkState {
429 next_index: usize,
430 consumed_prefix: usize,
431 stopped: bool,
432}
433
434enum OrderedWorkerMessage<R> {
435 Output(usize, HawDBResult<R>),
436 Stopped(RuntimeCancellationReason),
437}
438
439fn run_sequential_index_stream<R, F, C>(
440 input_count: usize,
441 context: Option<&RuntimeTaskContext>,
442 operation: &F,
443 consume: &mut C,
444) -> HawDBResult<BoundedOrderedStreamReport>
445where
446 F: Fn(usize) -> HawDBResult<R> + Sync,
447 C: FnMut(usize, R) -> HawDBResult<BoundedOrderedStreamControl>,
448{
449 let mut report = BoundedOrderedStreamReport::default();
450 for index in 0..input_count {
451 runtime_checkpoint(context)?;
452 let output = operation(index)?;
453 report.peak_reorder_entries = 1;
454 if consume(index, output)? == BoundedOrderedStreamControl::Stop {
455 report.stopped_early = true;
456 break;
457 }
458 }
459 runtime_checkpoint(context)?;
460 Ok(report)
461}
462
463fn claim_ordered_index(
464 window: &OrderedWorkWindow,
465 input_count: usize,
466 worker_count: usize,
467 context: Option<&RuntimeTaskContext>,
468) -> Option<usize> {
469 let mut state = lock_recover(&window.state);
470 loop {
471 if state.stopped || context_checkpoint(context).is_err() {
472 state.stopped = true;
473 window.changed.notify_all();
474 return None;
475 }
476 let window_end = state.consumed_prefix.saturating_add(worker_count);
477 if state.next_index < input_count && state.next_index < window_end {
478 let index = state.next_index;
479 state.next_index = state.next_index.saturating_add(1);
480 return Some(index);
481 }
482 if state.next_index == input_count {
483 return None;
484 }
485 let (next_state, _) = window
486 .changed
487 .wait_timeout(state, ORDERED_STREAM_CHECK_INTERVAL)
488 .unwrap_or_else(|poisoned| poisoned.into_inner());
489 state = next_state;
490 }
491}
492
493fn advance_ordered_prefix(window: &OrderedWorkWindow, consumed_prefix: usize) {
494 let mut state = lock_recover(&window.state);
495 state.consumed_prefix = state.consumed_prefix.max(consumed_prefix);
496 window.changed.notify_all();
497}
498
499fn stop_ordered_window(window: &OrderedWorkWindow) {
500 let mut state = lock_recover(&window.state);
501 state.stopped = true;
502 window.changed.notify_all();
503}
504
505fn context_checkpoint(
506 context: Option<&RuntimeTaskContext>,
507) -> Result<(), RuntimeCancellationReason> {
508 context.map_or(Ok(()), RuntimeTaskContext::checkpoint)
509}
510
511fn runtime_checkpoint(context: Option<&RuntimeTaskContext>) -> HawDBResult<()> {
512 context_checkpoint(context).map_err(runtime_stopped_error)
513}
514
515fn runtime_stopped_error(reason: RuntimeCancellationReason) -> HawDBError {
516 HawDBError::Execution(format!("runtime task stopped: {reason}"))
517}
518
519fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
520 mutex
521 .lock()
522 .unwrap_or_else(|poisoned| poisoned.into_inner())
523}
524
525impl Default for BoundedExecutor {
526 fn default() -> Self {
527 Self::new(NonZeroUsize::MIN)
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::{BoundedExecutor, SharedExecutorPool, SharedExecutorPoolError};
534 use hawdb_core::{RuntimeCancellationReason, RuntimeCancellationToken, RuntimeTaskContext};
535 use std::num::NonZeroUsize;
536 use std::sync::atomic::{AtomicUsize, Ordering};
537
538 #[test]
539 fn parallel_map_preserves_input_order() {
540 let executor = BoundedExecutor::new(NonZeroUsize::new(4).unwrap());
541 let output = executor.map_ordered(&[3, 1, 4, 2], |value| value * value);
542 assert_eq!(output, vec![9, 1, 16, 4]);
543 }
544
545 #[test]
546 fn shared_pool_respects_the_requested_worker_limit() {
547 let default_workers = SharedExecutorPool::shared_default().unwrap().worker_count();
548 let bounded_workers = SharedExecutorPool::shared_bounded(NonZeroUsize::new(2).unwrap())
549 .unwrap()
550 .worker_count();
551
552 assert_eq!(bounded_workers, default_workers.min(2));
553 }
554
555 #[test]
556 fn bounded_executor_exposes_pool_degradation() {
557 let executor = BoundedExecutor {
558 max_parallelism: NonZeroUsize::new(2).unwrap(),
559 pool: Err(SharedExecutorPoolError("injected failure".to_string())),
560 };
561
562 assert!(executor.is_degraded());
563 assert!(executor.pool_error().is_some());
564 assert_eq!(executor.map_ordered(&[1, 2], |value| value * 2), [2, 4]);
565 }
566
567 #[test]
568 fn parallel_map_respects_the_worker_bound() {
569 let executor = BoundedExecutor::new(NonZeroUsize::new(2).unwrap());
570 let active = AtomicUsize::new(0);
571 let peak = AtomicUsize::new(0);
572
573 let output = executor.map_ordered(&[1, 2, 3, 4], |value| {
574 let current = active.fetch_add(1, Ordering::SeqCst) + 1;
575 peak.fetch_max(current, Ordering::SeqCst);
576 std::thread::yield_now();
577 active.fetch_sub(1, Ordering::SeqCst);
578 value * 2
579 });
580
581 assert_eq!(output, vec![2, 4, 6, 8]);
582 assert!(peak.load(Ordering::SeqCst) <= 2);
583 }
584
585 #[test]
586 fn controlled_parallel_map_stops_between_inputs() {
587 let executor = BoundedExecutor::new(NonZeroUsize::MIN);
588 let token = RuntimeCancellationToken::new();
589 let context = RuntimeTaskContext::without_deadline(token.clone());
590 let visited = AtomicUsize::new(0);
591
592 let result = executor.map_ordered_with_context(&[1, 2, 3], &context, |value| {
593 visited.fetch_add(1, Ordering::SeqCst);
594 if *value == 1 {
595 token.cancel();
596 }
597 value * 2
598 });
599
600 assert_eq!(result, Err(RuntimeCancellationReason::Cancelled));
601 assert_eq!(visited.load(Ordering::SeqCst), 1);
602 }
603
604 #[test]
605 fn map_ordered_matches_the_sequential_fallback_under_reversed_durations() {
606 let inputs = [0_usize, 1, 2, 3, 4, 5];
607 let operation = |value: &usize| {
608 std::thread::sleep(std::time::Duration::from_millis(
609 (inputs.len() - 1 - value) as u64 * 4,
610 ));
611 value * value
612 };
613 let sequential = BoundedExecutor {
614 max_parallelism: NonZeroUsize::new(2).unwrap(),
615 pool: Err(SharedExecutorPoolError("injected failure".to_string())),
616 };
617 let expected = sequential.map_ordered(&inputs, operation);
618
619 let completed = AtomicUsize::new(0);
620 let output = BoundedExecutor::new(NonZeroUsize::MIN).map_ordered(&inputs, |value| {
621 completed.fetch_add(1, Ordering::SeqCst);
622 operation(value)
623 });
624
625 assert_eq!(output, expected);
626 assert_eq!(output, inputs.map(|value| value * value));
627 assert_eq!(completed.load(Ordering::SeqCst), inputs.len());
628 }
629
630 #[test]
631 fn controlled_map_ordered_matches_the_uncontrolled_output() {
632 let inputs = [4_usize, 3, 2, 1];
633 let operation = |value: &usize| {
634 std::thread::sleep(std::time::Duration::from_millis(*value as u64 * 3));
635 value + 10
636 };
637 let uncontrolled = BoundedExecutor::new(NonZeroUsize::MIN).map_ordered(&inputs, operation);
638 let context = RuntimeTaskContext::without_deadline(RuntimeCancellationToken::new());
639
640 let controlled = BoundedExecutor::new(NonZeroUsize::MIN)
641 .map_ordered_with_context(&inputs, &context, operation);
642
643 assert_eq!(controlled, Ok(uncontrolled));
644 }
645
646 #[test]
647 fn controlled_map_ordered_rejects_a_cancelled_context_before_running_work() {
648 let token = RuntimeCancellationToken::new();
649 token.cancel();
650 let context = RuntimeTaskContext::without_deadline(token);
651 let visited = AtomicUsize::new(0);
652
653 let result = BoundedExecutor::new(NonZeroUsize::MIN).map_ordered_with_context(
654 &[1, 2, 3],
655 &context,
656 |value| {
657 visited.fetch_add(1, Ordering::SeqCst);
658 value * 2
659 },
660 );
661
662 assert_eq!(result, Err(RuntimeCancellationReason::Cancelled));
663 assert_eq!(visited.load(Ordering::SeqCst), 0);
664 }
665}