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