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    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    /// Materializes one output per input in input order, regardless of completion
141    /// order. An output that is itself a `Result` does not short-circuit workers.
142    /// This bounds worker concurrency, not the collected vector's memory.
143    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    /// The materializing map with cooperative cancellation checks. In-flight
192    /// callbacks may finish after cancellation; no partial vector is returned.
193    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}