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