Skip to main content

weavatrix_scan/
runtime.rs

1#![allow(clippy::missing_const_for_thread_local)]
2
3use crate::pool::{Job, ThreadPool};
4use std::cell::RefCell;
5use std::fmt;
6use std::io;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, OnceLock};
9use std::time::Duration;
10
11thread_local! {
12    static ACTIVE_RUNTIMES: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
13}
14
15/// A job accepted by an embeddable [`ParallelExecutor`].
16pub type ParallelJob = Box<dyn FnOnce() + Send + 'static>;
17
18/// Adapter contract for an application-owned thread pool.
19///
20/// Implementations must either accept `job` exactly once or return an error
21/// without retaining it. `busy_timeout` lets bounded pools reject work instead
22/// of indefinitely waiting for capacity.
23pub trait ParallelExecutor: Send + Sync + 'static {
24    /// Maximum useful concurrent jobs for this executor.
25    fn parallelism(&self) -> usize;
26
27    /// Attempts to schedule one job.
28    ///
29    /// # Errors
30    ///
31    /// Returns an I/O error when the pool is closed, saturated past
32    /// `busy_timeout`, or otherwise cannot accept the job.
33    fn try_execute(&self, job: ParallelJob, busy_timeout: Option<Duration>) -> io::Result<()>;
34}
35
36enum Executor {
37    Global,
38    Dedicated(Arc<ThreadPool>),
39    External(Arc<dyn ParallelExecutor>),
40}
41
42struct RuntimeInner {
43    id: u64,
44    executor: Executor,
45    busy_timeout: Option<Duration>,
46}
47
48/// Selects where parallel traversal jobs execute.
49///
50/// The default uses the process-wide Weavatrix pool. Dedicated pools are
51/// joined on last drop. External executors receive the configured busy timeout
52/// and may reject submission without leaving a traversal waiting for a worker.
53#[derive(Clone)]
54pub struct ParallelRuntime {
55    inner: Arc<RuntimeInner>,
56}
57
58impl ParallelRuntime {
59    /// Returns the process-wide default runtime.
60    #[must_use]
61    pub fn global() -> Self {
62        static RUNTIME: OnceLock<ParallelRuntime> = OnceLock::new();
63        RUNTIME
64            .get_or_init(|| Self::new(Executor::Global, None))
65            .clone()
66    }
67
68    /// Creates an owned pool with exactly `parallelism.max(1)` workers.
69    ///
70    /// # Errors
71    ///
72    /// Returns an operating-system thread creation error.
73    pub fn dedicated(parallelism: usize) -> io::Result<Self> {
74        let pool = ThreadPool::with_workers(parallelism.max(1))?;
75        Ok(Self::new(Executor::Dedicated(Arc::new(pool)), None))
76    }
77
78    /// Uses an application-owned executor.
79    #[must_use]
80    pub fn external(executor: Arc<dyn ParallelExecutor>) -> Self {
81        Self::new(Executor::External(executor), None)
82    }
83
84    /// Supplies the maximum wait an external executor may use to accept work.
85    #[must_use]
86    pub fn with_busy_timeout(mut self, busy_timeout: Option<Duration>) -> Self {
87        Arc::make_mut(&mut self.inner).busy_timeout = busy_timeout;
88        self
89    }
90
91    /// Maximum useful worker count advertised by this runtime.
92    #[must_use]
93    pub fn parallelism(&self) -> usize {
94        match &self.inner.executor {
95            Executor::Global => ThreadPool::global().workers(),
96            Executor::Dedicated(pool) => pool.workers(),
97            Executor::External(executor) => executor.parallelism().max(1),
98        }
99    }
100
101    pub(crate) fn is_worker_thread(&self) -> bool {
102        ACTIVE_RUNTIMES.with(|active| active.borrow().contains(&self.inner.id))
103    }
104
105    pub(crate) fn try_execute<F>(&self, job: F) -> io::Result<()>
106    where
107        F: FnOnce() + Send + 'static,
108    {
109        let id = self.inner.id;
110        let wrapped: Job = Box::new(move || {
111            let _guard = ActiveRuntimeGuard::enter(id);
112            job();
113        });
114        match &self.inner.executor {
115            Executor::Global => ThreadPool::global().execute(wrapped),
116            Executor::Dedicated(pool) => pool.execute(wrapped),
117            Executor::External(executor) => executor.try_execute(wrapped, self.inner.busy_timeout),
118        }
119    }
120
121    fn new(executor: Executor, busy_timeout: Option<Duration>) -> Self {
122        static NEXT_RUNTIME_ID: AtomicU64 = AtomicU64::new(1);
123        Self {
124            inner: Arc::new(RuntimeInner {
125                id: NEXT_RUNTIME_ID.fetch_add(1, Ordering::Relaxed),
126                executor,
127                busy_timeout,
128            }),
129        }
130    }
131}
132
133impl Default for ParallelRuntime {
134    fn default() -> Self {
135        Self::global()
136    }
137}
138
139impl fmt::Debug for ParallelRuntime {
140    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141        let kind = match &self.inner.executor {
142            Executor::Global => "global",
143            Executor::Dedicated(_) => "dedicated",
144            Executor::External(_) => "external",
145        };
146        formatter
147            .debug_struct("ParallelRuntime")
148            .field("kind", &kind)
149            .field("parallelism", &self.parallelism())
150            .field("busy_timeout", &self.inner.busy_timeout)
151            .finish()
152    }
153}
154
155impl Clone for RuntimeInner {
156    fn clone(&self) -> Self {
157        Self {
158            id: self.id,
159            executor: match &self.executor {
160                Executor::Global => Executor::Global,
161                Executor::Dedicated(pool) => Executor::Dedicated(Arc::clone(pool)),
162                Executor::External(executor) => Executor::External(Arc::clone(executor)),
163            },
164            busy_timeout: self.busy_timeout,
165        }
166    }
167}
168
169struct ActiveRuntimeGuard;
170
171impl ActiveRuntimeGuard {
172    fn enter(id: u64) -> Self {
173        ACTIVE_RUNTIMES.with(|active| active.borrow_mut().push(id));
174        Self
175    }
176}
177
178impl Drop for ActiveRuntimeGuard {
179    fn drop(&mut self) {
180        ACTIVE_RUNTIMES.with(|active| {
181            active.borrow_mut().pop();
182        });
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::{ParallelExecutor, ParallelJob, ParallelRuntime};
189    use std::io;
190    use std::sync::{Arc, mpsc};
191    use std::time::Duration;
192
193    struct Inline;
194
195    impl ParallelExecutor for Inline {
196        fn parallelism(&self) -> usize {
197            1
198        }
199
200        fn try_execute(&self, job: ParallelJob, _busy_timeout: Option<Duration>) -> io::Result<()> {
201            job();
202            Ok(())
203        }
204    }
205
206    #[test]
207    fn dedicated_runtime_executes_and_joins() {
208        let runtime = ParallelRuntime::dedicated(2).unwrap();
209        let (sender, receiver) = mpsc::channel();
210        runtime
211            .try_execute(move || sender.send(9).unwrap())
212            .unwrap();
213        assert_eq!(receiver.recv().unwrap(), 9);
214    }
215
216    #[test]
217    fn external_runtime_marks_nested_execution() {
218        let runtime = ParallelRuntime::external(Arc::new(Inline));
219        let nested = runtime.clone();
220        let (sender, receiver) = mpsc::channel();
221        runtime
222            .try_execute(move || sender.send(nested.is_worker_thread()).unwrap())
223            .unwrap();
224        assert!(receiver.recv().unwrap());
225    }
226}