Skip to main content

hawdb_executor/
concurrent.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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        // Select the existing bounded sequential paths without attempting a spawn.
50        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    /// Materializes one output per input in input order, regardless of completion
150    /// order. An output that is itself a `Result` does not short-circuit workers.
151    /// This bounds worker concurrency, not the collected vector's memory.
152    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    /// The materializing map with cooperative cancellation checks. In-flight
201    /// callbacks may finish after cancellation; no partial vector is returned.
202    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}