Skip to main content

linkrs_query/executor/streaming/
pool.rs

1//! Morsel-style query-level bounded worker pool for dynamic partition execution.
2//!
3//! Workers are created once per query. Partition tasks are dynamically claimed
4//! from a shared atomic counter — a "morsel queue" — providing natural load
5//! balancing: faster workers automatically process more partitions.
6//!
7//! This is the only parallel execution mechanism (replaces the per-Gather
8//! `ParallelPartitionCoordinator`). The [`TaskScheduler`] trait abstracts
9//! over pool implementations.
10
11use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
12use std::sync::mpsc::{
13    self, Receiver, RecvTimeoutError, Sender, SyncSender, TryRecvError, TrySendError,
14};
15use std::sync::{Arc, Mutex};
16use std::thread::{self, JoinHandle};
17use std::time::{Duration, Instant};
18
19use crate::executor::base::{MemoryBudget, MemoryReservation};
20use linkrs_core::error::QueryError;
21
22use super::chunk::DataChunk;
23use super::executor::StreamingExecutor;
24use super::runtime::ExecutionRuntime;
25
26const CHANNEL_WAIT: Duration = Duration::from_millis(2);
27
28/// Abstract task scheduler for parallel partition execution.
29///
30/// Implementations manage a set of worker threads that dynamically claim
31/// partition tasks from a shared atomic counter for natural load balancing.
32pub trait TaskScheduler: Send + Sync + std::fmt::Debug {
33    /// Submit a batch of partition tasks for execution.
34    fn submit(&self, batch: Arc<PartitionBatch>);
35    /// Number of worker threads in this scheduler.
36    fn max_workers(&self) -> usize;
37}
38
39/// Engine-level shared scheduler.
40///
41/// Created once at database startup and reused across all queries.  Each
42/// query receives an `Arc` reference through its bindings.  Workers are
43/// created once and persist across query boundaries, eliminating the
44/// per-query thread overhead of the old `MorselWorkerPool` pattern.
45///
46/// Query teardown does not join or shut down the shared workers — only
47/// engine shutdown calls [`SharedScheduler::shutdown`].
48#[derive(Debug)]
49pub struct SharedScheduler {
50    pool: Arc<MorselWorkerPool>,
51}
52
53impl SharedScheduler {
54    /// Create a new shared scheduler with `max_workers` persistent threads.
55    ///
56    /// At least one worker is always created.  Pass `0` to use the default
57    /// (typically number of CPU cores, minimum 1).
58    pub fn new(max_workers: usize) -> Self {
59        Self {
60            pool: Arc::new(MorselWorkerPool::new(max_workers.max(1))),
61        }
62    }
63
64    /// Inject this scheduler into the given runtime.
65    ///
66    /// This makes the runtime's worker pool point to the shared scheduler's
67    /// underlying [`MorselWorkerPool`], so that all Exchange/Gather operators
68    /// in that query use the shared worker threads.
69    pub fn apply_to_runtime(&self, runtime: &super::runtime::ExecutionRuntime) {
70        runtime.set_shared_scheduler_raw(Some(self.pool.clone() as Arc<dyn TaskScheduler>));
71    }
72
73    /// Number of workers in this scheduler.
74    pub fn max_workers(&self) -> usize {
75        self.pool.max_workers()
76    }
77
78    /// Shut down all workers and wait for them to exit.
79    ///
80    /// Called during engine shutdown.  After this, no further batches can
81    /// be submitted.  Must only be called once no more queries need the
82    /// scheduler.
83    pub fn shutdown(&mut self) {
84        // Arc::get_mut only succeeds when no other references exist.
85        // During orderly shutdown this should be the case because all
86        // queries have completed.
87        if let Some(pool) = Arc::get_mut(&mut self.pool) {
88            pool.shutdown();
89        }
90        // If other references remain, workers will exit when their
91        // stop flag or channel closure is detected (via detach on drop).
92    }
93
94    /// Shut down workers through a shared reference.
95    ///
96    /// Signals the stop flag and closes the batch channel so workers exit
97    /// promptly, even while other references (in-flight query runtimes)
98    /// still exist.  Worker threads are not joined here; they terminate
99    /// after observing the stop flag / closed channel.
100    pub fn shutdown_shared(&self) {
101        self.pool.shutdown_shared();
102    }
103}
104
105#[derive(Debug)]
106pub struct BufferedChunk {
107    pub(crate) chunk: DataChunk,
108    pub(crate) _reservation: MemoryReservation,
109    pub(crate) bytes: usize,
110}
111
112#[derive(Debug)]
113pub enum PartitionMessage {
114    Chunk(BufferedChunk),
115    Finished,
116}
117
118/// Run a partition tree, sending output chunks through the given sender.
119/// This is the core worker loop: open, advance in a loop, close.
120#[allow(clippy::too_many_arguments)]
121pub(crate) fn run_partition(
122    tree: &mut StreamingExecutor,
123    sender: &SyncSender<PartitionMessage>,
124    stop: &AtomicBool,
125    runtime: &ExecutionRuntime,
126    queued_chunks: &AtomicUsize,
127    queued_chunks_peak: &AtomicUsize,
128    queued_bytes: &AtomicUsize,
129    queued_bytes_peak: &AtomicUsize,
130) -> Result<(), QueryError> {
131    tree.open()?;
132    let execution_result = (|| {
133        while !stop.load(Ordering::Relaxed) {
134            runtime.ensure_not_cancelled()?;
135            let Some(chunk) = tree.advance()? else {
136                send_message(sender, PartitionMessage::Finished, stop, runtime)?;
137                return Ok(());
138            };
139            let bytes = MemoryBudget::estimate_rows_memory(&chunk.rows);
140            let reservation = runtime.memory_budget.reserve(bytes)?;
141            let message = PartitionMessage::Chunk(BufferedChunk {
142                chunk,
143                _reservation: reservation,
144                bytes,
145            });
146            send_chunk(
147                sender,
148                message,
149                stop,
150                runtime,
151                queued_chunks,
152                queued_chunks_peak,
153                queued_bytes,
154                queued_bytes_peak,
155            )?;
156        }
157        Ok(())
158    })();
159    let close_result = tree.close_tree();
160    execution_result.and(close_result)
161}
162
163#[allow(clippy::too_many_arguments)]
164fn send_chunk(
165    sender: &SyncSender<PartitionMessage>,
166    mut message: PartitionMessage,
167    stop: &AtomicBool,
168    runtime: &ExecutionRuntime,
169    queued_chunks: &AtomicUsize,
170    queued_chunks_peak: &AtomicUsize,
171    queued_bytes: &AtomicUsize,
172    queued_bytes_peak: &AtomicUsize,
173) -> Result<(), QueryError> {
174    loop {
175        if stop.load(Ordering::Relaxed) || runtime.is_cancelled() {
176            return Ok(());
177        }
178        let bytes = match &message {
179            PartitionMessage::Chunk(buffered) => buffered.bytes,
180            PartitionMessage::Finished => 0,
181        };
182        let pre_count = queued_chunks.fetch_add(1, Ordering::Relaxed);
183        let pre_bytes = queued_bytes.fetch_add(bytes, Ordering::Relaxed);
184        match sender.try_send(message) {
185            Ok(()) => {
186                update_peak_value(queued_chunks_peak, pre_count + 1);
187                update_peak_value(queued_bytes_peak, pre_bytes + bytes);
188                return Ok(());
189            }
190            Err(TrySendError::Full(returned)) => {
191                queued_chunks.fetch_sub(1, Ordering::Relaxed);
192                queued_bytes.fetch_sub(bytes, Ordering::Relaxed);
193                message = returned;
194                thread::sleep(CHANNEL_WAIT);
195            }
196            Err(TrySendError::Disconnected(_)) => {
197                queued_chunks.fetch_sub(1, Ordering::Relaxed);
198                queued_bytes.fetch_sub(bytes, Ordering::Relaxed);
199                return Ok(());
200            }
201        }
202    }
203}
204
205pub(crate) fn update_peak_value(peak: &AtomicUsize, candidate: usize) {
206    let mut prev = peak.load(Ordering::Relaxed);
207    while candidate > prev {
208        match peak.compare_exchange_weak(prev, candidate, Ordering::Relaxed, Ordering::Relaxed) {
209            Ok(_) => return,
210            Err(actual) => prev = actual,
211        }
212    }
213}
214
215fn send_message(
216    sender: &SyncSender<PartitionMessage>,
217    mut message: PartitionMessage,
218    stop: &AtomicBool,
219    runtime: &ExecutionRuntime,
220) -> Result<(), QueryError> {
221    loop {
222        if stop.load(Ordering::Relaxed) || runtime.is_cancelled() {
223            return Ok(());
224        }
225        match sender.try_send(message) {
226            Ok(()) => return Ok(()),
227            Err(TrySendError::Full(returned)) => {
228                message = returned;
229                thread::sleep(CHANNEL_WAIT);
230            }
231            Err(TrySendError::Disconnected(_)) => return Ok(()),
232        }
233    }
234}
235
236pub(crate) fn release_queue_metrics(
237    queued_chunks: &AtomicUsize,
238    queued_bytes: &AtomicUsize,
239    bytes: usize,
240) {
241    queued_chunks.fetch_sub(1, Ordering::Relaxed);
242    queued_bytes.fetch_sub(bytes, Ordering::Relaxed);
243}
244
245/// A batch of partition tasks submitted to the pool for execution.
246///
247/// Workers dynamically claim partition indices via `next_index` and execute
248/// the corresponding tree, sending output chunks through `senders[partition_id]`.
249/// Partition trees are wrapped in `Mutex` to satisfy `Send + Sync` for `Arc<PartitionBatch>`
250/// (each index is claimed by at most one worker via the atomic counter, so contention
251/// on the mutex is limited to the brief `take()` window).
252pub struct PartitionBatch {
253    pub partitions: Vec<Mutex<Option<StreamingExecutor>>>,
254    pub senders: Vec<SyncSender<PartitionMessage>>,
255    pub error_tx: Sender<(usize, QueryError)>,
256    pub next_index: AtomicUsize,
257    pub total: usize,
258    pub runtime: Arc<ExecutionRuntime>,
259    pub worker_time_us: Arc<AtomicU64>,
260    pub buffered_chunks: Arc<AtomicUsize>,
261    pub buffered_bytes: Arc<AtomicUsize>,
262    pub buffered_chunks_peak: Arc<AtomicUsize>,
263    pub buffered_bytes_peak: Arc<AtomicUsize>,
264    pub stop: Arc<AtomicBool>,
265}
266
267impl PartitionBatch {
268    /// Create a new batch and return it together with per-partition receivers
269    /// and an error receiver.
270    pub(crate) fn new(
271        partitions: Vec<StreamingExecutor>,
272        runtime: Arc<ExecutionRuntime>,
273        max_buffered_chunks: usize,
274    ) -> (
275        Self,
276        Vec<Receiver<PartitionMessage>>,
277        Receiver<(usize, QueryError)>,
278    ) {
279        let partition_count = partitions.len();
280        let capacity = max_buffered_chunks.max(1);
281        let mut senders = Vec::with_capacity(partition_count);
282        let mut receivers = Vec::with_capacity(partition_count);
283        for _ in 0..partition_count {
284            let (tx, rx) = mpsc::sync_channel(capacity);
285            senders.push(tx);
286            receivers.push(rx);
287        }
288        let (error_tx, error_rx) = mpsc::channel();
289
290        let batch = Self {
291            partitions: partitions
292                .into_iter()
293                .map(|p| Mutex::new(Some(p)))
294                .collect(),
295            senders,
296            error_tx,
297            next_index: AtomicUsize::new(0),
298            total: partition_count,
299            runtime,
300            worker_time_us: Arc::new(AtomicU64::new(0)),
301            buffered_chunks: Arc::new(AtomicUsize::new(0)),
302            buffered_bytes: Arc::new(AtomicUsize::new(0)),
303            buffered_chunks_peak: Arc::new(AtomicUsize::new(0)),
304            buffered_bytes_peak: Arc::new(AtomicUsize::new(0)),
305            stop: Arc::new(AtomicBool::new(false)),
306        };
307        (batch, receivers, error_rx)
308    }
309}
310
311/// Handle for consuming results from a submitted batch.
312///
313/// Returned by `MorselWorkerPool::submit()`. Provides per-partition chunk
314/// access via `next_for_partition()`.
315#[derive(Debug)]
316pub struct PartitionHandle {
317    pub receivers: Vec<Receiver<PartitionMessage>>,
318    pub error_rx: Mutex<Receiver<(usize, QueryError)>>,
319    pub stop: Arc<AtomicBool>,
320    pub partition_count: usize,
321    pub buffered_chunks: Arc<AtomicUsize>,
322    pub buffered_bytes: Arc<AtomicUsize>,
323    pub buffered_chunks_peak: Arc<AtomicUsize>,
324    pub buffered_bytes_peak: Arc<AtomicUsize>,
325    pub worker_time_us: Arc<AtomicU64>,
326    pub started_at: Instant,
327    pub profile_recorded: bool,
328    pub runtime: Arc<ExecutionRuntime>,
329    worker_count: usize,
330}
331
332impl PartitionHandle {
333    /// Create a handle from a batch and its associated receivers.
334    pub(crate) fn from_batch(
335        batch: &Arc<PartitionBatch>,
336        receivers: Vec<Receiver<PartitionMessage>>,
337        error_rx: Receiver<(usize, QueryError)>,
338        runtime: Arc<ExecutionRuntime>,
339        started_at: Instant,
340        worker_count: usize,
341    ) -> Self {
342        Self {
343            receivers,
344            error_rx: Mutex::new(error_rx),
345            stop: batch.stop.clone(),
346            partition_count: batch.total,
347            buffered_chunks: batch.buffered_chunks.clone(),
348            buffered_bytes: batch.buffered_bytes.clone(),
349            buffered_chunks_peak: batch.buffered_chunks_peak.clone(),
350            buffered_bytes_peak: batch.buffered_bytes_peak.clone(),
351            worker_time_us: Arc::clone(&batch.worker_time_us),
352            started_at,
353            profile_recorded: false,
354            runtime,
355            worker_count,
356        }
357    }
358
359    /// Pull one chunk from an individual partition, blocking until data is
360    /// available instead of polling at the channel wait interval.
361    ///
362    /// This is the drain fast path for the [`GatherOperator::Concatenate`]
363    /// gather, which reads exactly one partition at a time: a blocking
364    /// receive wakes the instant the producer sends a chunk, removing the
365    /// up-to-`CHANNEL_WAIT` poll latency from the steady-state drain and
366    /// avoiding producer/consumer wakeup gaps.  Cancellation and worker
367    /// errors are still observed at each chunk boundary because producers
368    /// exit promptly on `stop`/cancel, which disconnects their sender.
369    pub fn next_for_partition_blocking(
370        &mut self,
371        partition_id: usize,
372    ) -> Result<Option<DataChunk>, QueryError> {
373        if partition_id >= self.receivers.len() {
374            return Err(QueryError::execution(format!(
375                "Morsel partition handle has no partition {partition_id}",
376            )));
377        }
378        match self.receivers[partition_id].recv() {
379            Ok(PartitionMessage::Chunk(buffered)) => {
380                release_queue_metrics(&self.buffered_chunks, &self.buffered_bytes, buffered.bytes);
381                Ok(Some(buffered.chunk))
382            }
383            Ok(PartitionMessage::Finished) => Ok(None),
384            Err(_) => {
385                self.check_worker_error()?;
386                if self.runtime.is_cancelled() {
387                    let _ = self.stop_and_join();
388                    return Err(QueryError::execution("Query cancelled".to_string()));
389                }
390                let _ = self.stop_and_join();
391                Err(QueryError::execution(format!(
392                    "Morsel partition {partition_id} disconnected before completion",
393                )))
394            }
395        }
396    }
397
398    /// Pull one chunk from an individual partition.
399    pub fn next_for_partition(
400        &mut self,
401        partition_id: usize,
402    ) -> Result<Option<DataChunk>, QueryError> {
403        if partition_id >= self.receivers.len() {
404            return Err(QueryError::execution(format!(
405                "Morsel partition handle has no partition {partition_id}",
406            )));
407        }
408
409        loop {
410            self.check_worker_error()?;
411            if self.runtime.is_cancelled() {
412                let _ = self.stop_and_join();
413                return Err(QueryError::execution("Query cancelled".to_string()));
414            }
415            match self.receivers[partition_id].recv_timeout(CHANNEL_WAIT) {
416                Ok(PartitionMessage::Chunk(buffered)) => {
417                    release_queue_metrics(
418                        &self.buffered_chunks,
419                        &self.buffered_bytes,
420                        buffered.bytes,
421                    );
422                    return Ok(Some(buffered.chunk));
423                }
424                Ok(PartitionMessage::Finished) => {
425                    return Ok(None);
426                }
427                Err(RecvTimeoutError::Timeout) => continue,
428                Err(RecvTimeoutError::Disconnected) => {
429                    self.check_worker_error()?;
430                    let _ = self.stop_and_join();
431                    return Err(QueryError::execution(format!(
432                        "Morsel partition {partition_id} disconnected before completion",
433                    )));
434                }
435            }
436        }
437    }
438
439    pub fn stop_and_join(&mut self) -> Result<(), QueryError> {
440        self.stop.store(true, Ordering::Relaxed);
441        self.record_profile();
442        Ok(())
443    }
444
445    fn check_worker_error(&mut self) -> Result<(), QueryError> {
446        let error = { self.error_rx.lock().unwrap().try_recv() };
447        match error {
448            Ok((partition_id, error)) => {
449                let _ = self.stop_and_join();
450                Err(QueryError::execution(format!(
451                    "Morsel partition {partition_id} failed: {error}",
452                )))
453            }
454            Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => Ok(()),
455        }
456    }
457
458    fn record_profile(&mut self) {
459        if self.profile_recorded {
460            return;
461        }
462        self.profile_recorded = true;
463        let board = self.runtime.profile();
464        board.parallel_wall_time_us.fetch_add(
465            self.started_at.elapsed().as_micros() as u64,
466            Ordering::Relaxed,
467        );
468        board.parallel_work_time_us.fetch_add(
469            self.worker_time_us.load(Ordering::Relaxed),
470            Ordering::Relaxed,
471        );
472        // Report the number of workers that actually executed partitions:
473        // at most one worker per partition, so it is bounded by the
474        // partition count even when the pool has more threads.
475        board.parallel_workers.fetch_max(
476            self.worker_count.min(self.partition_count),
477            Ordering::Relaxed,
478        );
479        board.parallel_buffered_chunks_peak.fetch_max(
480            self.buffered_chunks_peak.load(Ordering::Relaxed),
481            Ordering::Relaxed,
482        );
483        board.parallel_buffered_bytes_peak.fetch_max(
484            self.buffered_bytes_peak.load(Ordering::Relaxed),
485            Ordering::Relaxed,
486        );
487    }
488}
489
490/// Query-level bounded worker pool with morsel-style dynamic task assignment.
491///
492/// Workers are created once per query and persist across multiple Exchange
493/// node invocations. Partition tasks are dynamically claimed from a shared
494/// atomic counter, so faster workers naturally process more work — this
495/// eliminates the static round-robin load imbalance of per-Gather threads.
496pub struct MorselWorkerPool {
497    batch_tx: Mutex<Option<Sender<Arc<PartitionBatch>>>>,
498    workers: Vec<JoinHandle<()>>,
499    stop: Arc<AtomicBool>,
500    max_workers: usize,
501}
502
503impl MorselWorkerPool {
504    /// Create a new pool with `max_workers` persistent worker threads.
505    pub fn new(max_workers: usize) -> Self {
506        let (batch_tx, batch_rx) = mpsc::channel::<Arc<PartitionBatch>>();
507        let batch_rx = Arc::new(Mutex::new(batch_rx));
508        let stop = Arc::new(AtomicBool::new(false));
509        let worker_count = max_workers.max(1);
510        let mut workers = Vec::with_capacity(worker_count);
511
512        for _ in 0..worker_count {
513            let rx = batch_rx.clone();
514            let stopper = stop.clone();
515            workers.push(thread::spawn(move || loop {
516                if stopper.load(Ordering::Relaxed) {
517                    return;
518                }
519                let batch = {
520                    let guard = rx.lock().unwrap();
521                    guard.recv()
522                };
523                match batch {
524                    Ok(batch) => {
525                        // Clone channels/stop before moving batch into catch_unwind
526                        // so we can propagate the panic as a query error.
527                        let error_tx = batch.error_tx.clone();
528                        let stop = batch.stop.clone();
529
530                        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
531                            Self::process_batch(batch, &stopper)
532                        }));
533                        if let Err(panic_payload) = result {
534                            let msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {
535                                s.to_string()
536                            } else if let Some(s) = panic_payload.downcast_ref::<String>() {
537                                s.clone()
538                            } else {
539                                "Worker panicked".to_string()
540                            };
541                            log::error!("Morsel worker panicked: {msg}");
542                            stop.store(true, Ordering::Relaxed);
543                            let _ = error_tx.send((usize::MAX, QueryError::execution(msg)));
544                            return;
545                        }
546                    }
547                    Err(_) => return,
548                }
549            }));
550        }
551
552        Self {
553            batch_tx: Mutex::new(Some(batch_tx)),
554            workers,
555            stop,
556            max_workers: worker_count,
557        }
558    }
559
560    /// Submit a batch of partition tasks for morsel-style execution.
561    ///
562    /// Workers dynamically claim partitions from the batch's shared atomic
563    /// counter.
564    pub(crate) fn submit(&self, batch: Arc<PartitionBatch>) {
565        let guard = self.batch_tx.lock().unwrap();
566        if let Some(ref tx) = *guard {
567            let workers_to_notify = self.max_workers.min(batch.total);
568            for _ in 0..workers_to_notify {
569                if tx.send(batch.clone()).is_err() {
570                    break;
571                }
572            }
573        }
574    }
575
576    /// Number of workers in this pool.
577    pub fn max_workers(&self) -> usize {
578        self.max_workers
579    }
580
581    /// Signal all workers to stop and wait for them to exit.
582    ///
583    /// Must be called from a non-worker thread — never from within
584    /// a worker that holds the final runtime reference.
585    pub fn shutdown(&mut self) {
586        self.stop.store(true, Ordering::Relaxed);
587        *self.batch_tx.get_mut().unwrap() = None;
588        for handle in self.workers.drain(..) {
589            let _ = handle.join();
590        }
591    }
592
593    /// Signal all workers to stop through a shared reference.
594    ///
595    /// Sets the stop flag and closes the batch channel.  Workers blocked on
596    /// `recv` observe the closed channel and exit; the stop flag covers
597    /// workers between batches.  Safe to call while queries still hold
598    /// runtime references to this pool.
599    pub fn shutdown_shared(&self) {
600        self.stop.store(true, Ordering::Relaxed);
601        if let Ok(mut guard) = self.batch_tx.lock() {
602            *guard = None;
603        }
604    }
605
606    /// Signal workers to stop without joining.
607    ///
608    /// Safe to call from any context (including a worker that may own
609    /// the last `Arc<ExecutionRuntime>`). Workers exit naturally when
610    /// they observe the stop flag or the closed channel.
611    pub fn detach(&mut self) {
612        self.stop.store(true, Ordering::Relaxed);
613        *self.batch_tx.get_mut().unwrap() = None;
614        self.workers.clear();
615    }
616
617    fn process_batch(batch: Arc<PartitionBatch>, stopper: &AtomicBool) {
618        loop {
619            if stopper.load(Ordering::Relaxed) || batch.runtime.is_cancelled() {
620                return;
621            }
622            let index = batch.next_index.fetch_add(1, Ordering::Relaxed);
623            if index >= batch.total {
624                return;
625            }
626
627            let mut tree = batch.partitions[index].lock().unwrap().take();
628            let Some(ref mut tree) = tree else {
629                continue;
630            };
631
632            let started = Instant::now();
633            let result = run_partition(
634                tree,
635                &batch.senders[index],
636                &batch.stop,
637                &batch.runtime,
638                &batch.buffered_chunks,
639                &batch.buffered_chunks_peak,
640                &batch.buffered_bytes,
641                &batch.buffered_bytes_peak,
642            );
643            batch
644                .worker_time_us
645                .fetch_add(started.elapsed().as_micros() as u64, Ordering::Relaxed);
646
647            if let Err(error) = result {
648                batch.stop.store(true, Ordering::Relaxed);
649                let _ = batch.error_tx.send((index, error));
650                return;
651            }
652        }
653    }
654}
655
656impl Drop for MorselWorkerPool {
657    fn drop(&mut self) {
658        // Use detach (not shutdown) to avoid joining our own thread when
659        // the last runtime reference is dropped inside a worker context.
660        self.detach();
661    }
662}
663
664impl TaskScheduler for MorselWorkerPool {
665    fn submit(&self, batch: Arc<PartitionBatch>) {
666        self.submit(batch);
667    }
668
669    fn max_workers(&self) -> usize {
670        self.max_workers
671    }
672}
673
674impl std::fmt::Debug for MorselWorkerPool {
675    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        f.debug_struct("MorselWorkerPool")
677            .field("max_workers", &self.max_workers)
678            .field("worker_count", &self.workers.len())
679            .finish()
680    }
681}