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