Skip to main content

hawdb_executor/
morsel.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
15//! Storage-neutral morsel partitioning and resource admission.
16
17use crate::concurrent::{BoundedOrderedStreamControl, BoundedOrderedStreamReport};
18use crate::{BoundedExecutor, QueryMemoryAccount, QueryMemoryLease, SharedExecutorPool};
19use hawdb_core::{HawDBError, Result, RuntimeTaskContext};
20use std::num::NonZeroUsize;
21use std::panic::{catch_unwind, AssertUnwindSafe};
22use std::sync::atomic::{AtomicUsize, Ordering};
23use std::sync::{Arc, Mutex};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct PipelineId(pub u32);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct MorselOrdinal(pub u64);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Morsel {
33    pub pipeline_id: PipelineId,
34    pub ordinal: MorselOrdinal,
35    pub start_row: usize,
36    pub row_count: usize,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct MorselAdmissionRequest {
41    pub pipeline_id: PipelineId,
42    pub input_rows: usize,
43    pub target_rows: NonZeroUsize,
44    pub requested_parallelism: NonZeroUsize,
45    pub bytes_per_worker: NonZeroUsize,
46    pub memory_budget_bytes: NonZeroUsize,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct MorselAdmission {
51    pipeline_id: PipelineId,
52    input_rows: usize,
53    target_rows: NonZeroUsize,
54    morsel_count: usize,
55    max_workers: usize,
56    reserved_bytes: usize,
57}
58
59impl MorselAdmission {
60    pub fn try_new(request: MorselAdmissionRequest) -> Result<Self> {
61        if request.input_rows == 0 {
62            return Ok(Self {
63                pipeline_id: request.pipeline_id,
64                input_rows: 0,
65                target_rows: request.target_rows,
66                morsel_count: 0,
67                max_workers: 0,
68                reserved_bytes: 0,
69            });
70        }
71
72        let memory_workers = request.memory_budget_bytes.get() / request.bytes_per_worker.get();
73        if memory_workers == 0 {
74            return Err(HawDBError::Execution(format!(
75                "morsel pipeline {} requires {} bytes for one worker, exceeding memory budget {}",
76                request.pipeline_id.0, request.bytes_per_worker, request.memory_budget_bytes
77            )));
78        }
79
80        let morsel_count = request.input_rows.div_ceil(request.target_rows.get());
81        let max_workers = request
82            .requested_parallelism
83            .get()
84            .min(memory_workers)
85            .min(morsel_count);
86        let reserved_bytes = max_workers.saturating_mul(request.bytes_per_worker.get());
87        Ok(Self {
88            pipeline_id: request.pipeline_id,
89            input_rows: request.input_rows,
90            target_rows: request.target_rows,
91            morsel_count,
92            max_workers,
93            reserved_bytes,
94        })
95    }
96
97    pub fn morsels(&self) -> MorselIter {
98        MorselIter {
99            pipeline_id: self.pipeline_id,
100            input_rows: self.input_rows,
101            target_rows: self.target_rows,
102            next_ordinal: 0,
103            morsel_count: self.morsel_count,
104        }
105    }
106
107    pub fn morsel_count(&self) -> usize {
108        self.morsel_count
109    }
110
111    pub fn max_workers(&self) -> usize {
112        self.max_workers
113    }
114
115    pub fn reserved_bytes(&self) -> usize {
116        self.reserved_bytes
117    }
118
119    fn morsel_at(&self, index: usize) -> Option<Morsel> {
120        if index >= self.morsel_count {
121            return None;
122        }
123        let start_row = index.saturating_mul(self.target_rows.get());
124        Some(Morsel {
125            pipeline_id: self.pipeline_id,
126            ordinal: MorselOrdinal(index as u64),
127            start_row,
128            row_count: self
129                .target_rows
130                .get()
131                .min(self.input_rows.saturating_sub(start_row)),
132        })
133    }
134}
135
136pub struct MorselIter {
137    pipeline_id: PipelineId,
138    input_rows: usize,
139    target_rows: NonZeroUsize,
140    next_ordinal: usize,
141    morsel_count: usize,
142}
143
144impl Iterator for MorselIter {
145    type Item = Morsel;
146
147    fn next(&mut self) -> Option<Self::Item> {
148        if self.next_ordinal == self.morsel_count {
149            return None;
150        }
151        let ordinal = self.next_ordinal;
152        let start_row = ordinal.saturating_mul(self.target_rows.get());
153        let row_count = self
154            .target_rows
155            .get()
156            .min(self.input_rows.saturating_sub(start_row));
157        self.next_ordinal = self.next_ordinal.saturating_add(1);
158        Some(Morsel {
159            pipeline_id: self.pipeline_id,
160            ordinal: MorselOrdinal(ordinal as u64),
161            start_row,
162            row_count,
163        })
164    }
165
166    fn size_hint(&self) -> (usize, Option<usize>) {
167        let remaining = self.morsel_count.saturating_sub(self.next_ordinal);
168        (remaining, Some(remaining))
169    }
170}
171
172impl ExactSizeIterator for MorselIter {}
173
174pub fn admit_morsels(request: MorselAdmissionRequest) -> Result<MorselAdmission> {
175    MorselAdmission::try_new(request)
176}
177
178/// Materializing sequential baseline for scheduler qualification and benchmarks.
179/// Production bounded output uses `SharedPoolMorselScheduler::execute_accounted_ordered`.
180#[derive(Debug, Default, Clone, Copy)]
181pub struct SequentialMorselScheduler;
182
183impl SequentialMorselScheduler {
184    pub fn execute<T>(
185        self,
186        admission: &MorselAdmission,
187        mut execute: impl FnMut(Morsel) -> Result<T>,
188    ) -> Result<Vec<T>> {
189        let mut output = Vec::with_capacity(admission.morsel_count);
190        for morsel in admission.morsels() {
191            output.push(execute(morsel)?);
192        }
193        Ok(output)
194    }
195}
196
197#[derive(Debug, Clone)]
198pub struct SharedPoolMorselScheduler {
199    pool: SharedExecutorPool,
200}
201
202impl SharedPoolMorselScheduler {
203    pub fn new(pool: SharedExecutorPool) -> Self {
204        Self { pool }
205    }
206
207    pub fn execute<T, F>(&self, admission: &MorselAdmission, execute: F) -> Result<Vec<T>>
208    where
209        T: Send,
210        F: Fn(Morsel) -> Result<T> + Sync,
211    {
212        if admission.morsel_count() == 0 {
213            return Ok(Vec::new());
214        }
215        let max_workers = NonZeroUsize::new(admission.max_workers()).ok_or_else(|| {
216            HawDBError::Execution("non-empty morsel admission reserved no workers".to_string())
217        })?;
218        let morsels = admission.morsels().collect::<Vec<_>>();
219        BoundedExecutor::with_pool(max_workers, self.pool.clone())
220            .map_ordered(&morsels, |morsel| execute_catching_panic(&execute, *morsel))
221            .into_iter()
222            .collect()
223    }
224
225    /// Typed resource failures stop cooperating siblings and preserve the first
226    /// resource cause. Ordinary callback errors retain input-ordered collection.
227    /// Use a child context when the parent will be reused for later work.
228    pub fn execute_with_context<T, F>(
229        &self,
230        admission: &MorselAdmission,
231        context: &RuntimeTaskContext,
232        execute: F,
233    ) -> Result<Vec<T>>
234    where
235        T: Send,
236        F: Fn(Morsel) -> Result<T> + Sync,
237    {
238        if admission.morsel_count() == 0 {
239            context.checkpoint().map_err(|reason| {
240                HawDBError::Execution(format!("runtime task stopped: {reason}"))
241            })?;
242            return Ok(Vec::new());
243        }
244        let max_workers = NonZeroUsize::new(admission.max_workers()).ok_or_else(|| {
245            HawDBError::Execution("non-empty morsel admission reserved no workers".to_string())
246        })?;
247        let morsels = admission.morsels().collect::<Vec<_>>();
248        let resource_failure = Mutex::new(None);
249        let outputs = BoundedExecutor::with_pool(max_workers, self.pool.clone())
250            .map_ordered_with_context(&morsels, context, |morsel| {
251                let result = execute_catching_panic(&execute, *morsel);
252                if let Err(error) = &result
253                    && crate::concurrent::is_resource_exhaustion(error)
254                {
255                    resource_failure
256                        .lock()
257                        .unwrap_or_else(|poisoned| poisoned.into_inner())
258                        .get_or_insert_with(|| error.clone());
259                    context.cancellation().cancel_for_resource_exhaustion();
260                }
261                result
262            });
263        if let Some(error) = resource_failure
264            .into_inner()
265            .unwrap_or_else(|poisoned| poisoned.into_inner())
266        {
267            return Err(error);
268        }
269        outputs
270            .map_err(|reason| HawDBError::Execution(format!("runtime task stopped: {reason}")))?
271            .into_iter()
272            .collect()
273    }
274
275    /// Streams worker outputs through a bounded channel and invokes the
276    /// coordinator consumer in stable morsel ordinal order.
277    ///
278    /// `output_reservation_bytes` is charged before the worker calls
279    /// `execute`. The returned resident estimate must fit that reservation.
280    /// The lease remains live through `consume`, so a consumer that retains
281    /// the value must transfer it into another query-owned account.
282    ///
283    /// A terminal typed resource refusal stops dispatch and signals resource
284    /// exhaustion on `task_context` before joining in-flight callbacks. Callbacks
285    /// must check that same context to stop cooperatively. The original resource
286    /// error is returned. Use a child context to isolate a reusable parent.
287    pub fn execute_accounted_ordered<T, F, C>(
288        &self,
289        admission: &MorselAdmission,
290        resources: MorselStreamResources<'_>,
291        execute: F,
292        mut consume: C,
293    ) -> Result<MorselStreamReport>
294    where
295        T: Send,
296        F: Fn(Morsel) -> Result<MorselOutput<T>> + Sync,
297        C: FnMut(Morsel, T) -> Result<MorselStreamControl>,
298    {
299        if admission.morsel_count() == 0 {
300            if let Some(context) = resources.task_context {
301                context.checkpoint().map_err(|reason| {
302                    HawDBError::Execution(format!("runtime task stopped: {reason}"))
303                })?;
304            }
305            return Ok(MorselStreamReport::default());
306        }
307        let max_workers = NonZeroUsize::new(admission.max_workers()).ok_or_else(|| {
308            HawDBError::Execution("non-empty morsel admission reserved no workers".to_string())
309        })?;
310        let counters = Arc::new(MorselOutputCounters::default());
311        let concurrent_report = BoundedExecutor::with_pool(max_workers, self.pool.clone())
312            .try_for_each_index_ordered(
313                admission.morsel_count(),
314                resources.task_context,
315                |index| {
316                    let morsel = admission
317                        .morsel_at(index)
318                        .expect("admitted morsel index is in range");
319                    let mut lease = resources
320                        .output_account
321                        .reserve(resources.output_reservation_bytes.get())?;
322                    let output = execute_catching_panic(&execute, morsel)?;
323                    if output.resident_bytes > resources.output_reservation_bytes.get() {
324                        return Err(HawDBError::Execution(format!(
325                            "morsel pipeline {} output at ordinal {} uses {} bytes, exceeding its {}-byte reservation",
326                            morsel.pipeline_id.0,
327                            morsel.ordinal.0,
328                            output.resident_bytes,
329                            resources.output_reservation_bytes
330                        )));
331                    }
332                    lease.shrink(
333                        resources.output_reservation_bytes.get() - output.resident_bytes,
334                    );
335                    Ok(PendingMorselOutput::new(
336                        output.value,
337                        output.resident_bytes,
338                        lease,
339                        Arc::clone(&counters),
340                    ))
341                },
342                |index, mut output| {
343                    let morsel = admission
344                        .morsel_at(index)
345                        .expect("admitted morsel index is in range");
346                    let value = output.take_value();
347                    let control = catch_unwind(AssertUnwindSafe(|| consume(morsel, value)))
348                        .unwrap_or_else(|_| {
349                            Err(HawDBError::Execution(format!(
350                                "morsel pipeline {} consumer panicked at ordinal {}",
351                                morsel.pipeline_id.0, morsel.ordinal.0
352                            )))
353                        })?;
354                    Ok(match control {
355                        MorselStreamControl::Continue => BoundedOrderedStreamControl::Continue,
356                        MorselStreamControl::Stop => BoundedOrderedStreamControl::Stop,
357                    })
358                },
359            )?;
360        Ok(MorselStreamReport::from_parts(concurrent_report, &counters))
361    }
362}
363
364#[derive(Debug, Clone, Copy)]
365pub struct MorselStreamResources<'a> {
366    pub task_context: Option<&'a RuntimeTaskContext>,
367    pub output_account: &'a QueryMemoryAccount,
368    pub output_reservation_bytes: NonZeroUsize,
369}
370
371#[derive(Debug)]
372pub struct MorselOutput<T> {
373    value: T,
374    resident_bytes: usize,
375}
376
377impl<T> MorselOutput<T> {
378    pub fn new(value: T, resident_bytes: usize) -> Self {
379        Self {
380            value,
381            resident_bytes,
382        }
383    }
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum MorselStreamControl {
388    Continue,
389    Stop,
390}
391
392#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
393pub struct MorselStreamReport {
394    pub peak_buffered_outputs: usize,
395    pub peak_buffered_output_bytes: usize,
396    pub peak_reorder_entries: usize,
397    pub stopped_early: bool,
398}
399
400impl MorselStreamReport {
401    fn from_parts(concurrent: BoundedOrderedStreamReport, counters: &MorselOutputCounters) -> Self {
402        Self {
403            peak_buffered_outputs: counters.peak_outputs.load(Ordering::Relaxed),
404            peak_buffered_output_bytes: counters.peak_bytes.load(Ordering::Relaxed),
405            peak_reorder_entries: concurrent.peak_reorder_entries,
406            stopped_early: concurrent.stopped_early,
407        }
408    }
409}
410
411#[derive(Debug, Default)]
412struct MorselOutputCounters {
413    current_outputs: AtomicUsize,
414    peak_outputs: AtomicUsize,
415    current_bytes: AtomicUsize,
416    peak_bytes: AtomicUsize,
417}
418
419struct PendingMorselOutput<T> {
420    value: Option<T>,
421    resident_bytes: usize,
422    _lease: QueryMemoryLease,
423    counters: Arc<MorselOutputCounters>,
424}
425
426impl<T> PendingMorselOutput<T> {
427    fn new(
428        value: T,
429        resident_bytes: usize,
430        lease: QueryMemoryLease,
431        counters: Arc<MorselOutputCounters>,
432    ) -> Self {
433        let outputs = counters.current_outputs.fetch_add(1, Ordering::Relaxed) + 1;
434        counters.peak_outputs.fetch_max(outputs, Ordering::Relaxed);
435        let bytes = counters
436            .current_bytes
437            .fetch_add(resident_bytes, Ordering::Relaxed)
438            .saturating_add(resident_bytes);
439        counters.peak_bytes.fetch_max(bytes, Ordering::Relaxed);
440        Self {
441            value: Some(value),
442            resident_bytes,
443            _lease: lease,
444            counters,
445        }
446    }
447
448    fn take_value(&mut self) -> T {
449        self.value
450            .take()
451            .expect("pending morsel output value is consumed once")
452    }
453}
454
455impl<T> Drop for PendingMorselOutput<T> {
456    fn drop(&mut self) {
457        self.counters
458            .current_outputs
459            .fetch_sub(1, Ordering::Relaxed);
460        self.counters
461            .current_bytes
462            .fetch_sub(self.resident_bytes, Ordering::Relaxed);
463    }
464}
465
466fn execute_catching_panic<T>(
467    execute: &(impl Fn(Morsel) -> Result<T> + Sync),
468    morsel: Morsel,
469) -> Result<T> {
470    catch_unwind(AssertUnwindSafe(|| execute(morsel))).unwrap_or_else(|_| {
471        Err(HawDBError::Execution(format!(
472            "morsel pipeline {} worker panicked at ordinal {}",
473            morsel.pipeline_id.0, morsel.ordinal.0
474        )))
475    })
476}
477
478/// Executes morsels in ordinal order, stopping at the first callback error.
479///
480/// This materializing baseline is consumed by the executor vectorization
481/// benchmark and `tests/morsel_oracle.rs`, which qualifies both materializing
482/// shared-pool methods and the production accounted stream against an independent
483/// row oracle. Parallel callbacks may already be running when one fails; their
484/// side effects need not match the sequential baseline's fail-fast behavior.
485pub fn execute_morsels_ordered<T>(
486    admission: &MorselAdmission,
487    mut execute: impl FnMut(Morsel) -> Result<T>,
488) -> Result<Vec<T>> {
489    SequentialMorselScheduler.execute(admission, &mut execute)
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::{QueryMemoryClass, QueryMemoryLedger};
496
497    fn request(input_rows: usize) -> MorselAdmissionRequest {
498        MorselAdmissionRequest {
499            pipeline_id: PipelineId(7),
500            input_rows,
501            target_rows: NonZeroUsize::new(64).unwrap(),
502            requested_parallelism: NonZeroUsize::new(8).unwrap(),
503            bytes_per_worker: NonZeroUsize::new(1024).unwrap(),
504            memory_budget_bytes: NonZeroUsize::new(3 * 1024).unwrap(),
505        }
506    }
507
508    #[test]
509    fn admission_is_bounded_by_work_and_memory() {
510        let admission = admit_morsels(request(130)).unwrap();
511
512        assert_eq!(admission.max_workers(), 3);
513        assert_eq!(admission.reserved_bytes(), 3 * 1024);
514        assert_eq!(
515            admission
516                .morsels()
517                .map(|morsel| (morsel.start_row, morsel.row_count))
518                .collect::<Vec<_>>(),
519            vec![(0, 64), (64, 64), (128, 2)]
520        );
521    }
522
523    #[test]
524    fn admission_rejects_a_worker_that_cannot_fit() {
525        let mut request = request(1);
526        request.bytes_per_worker = NonZeroUsize::new(4096).unwrap();
527
528        assert!(admit_morsels(request).is_err());
529    }
530
531    #[test]
532    fn ordered_executor_preserves_morsel_ordinals() {
533        let admission = MorselAdmission::try_new(request(130)).unwrap();
534        let ordinals = SequentialMorselScheduler
535            .execute(&admission, |morsel| Ok(morsel.ordinal.0))
536            .unwrap();
537
538        assert_eq!(ordinals, vec![0, 1, 2]);
539    }
540
541    #[test]
542    fn empty_input_reserves_no_workers() {
543        let admission = admit_morsels(request(0)).unwrap();
544
545        assert_eq!(admission.morsel_count(), 0);
546        assert_eq!(admission.max_workers(), 0);
547        assert_eq!(admission.reserved_bytes(), 0);
548    }
549
550    #[test]
551    fn shared_pool_scheduler_matches_sequential_ordinal_order() {
552        let admission = admit_morsels(request(1025)).unwrap();
553        let sequential = execute_morsels_ordered(&admission, |morsel| {
554            Ok((morsel.ordinal.0, morsel.start_row, morsel.row_count))
555        })
556        .unwrap();
557        let pool = SharedExecutorPool::new(NonZeroUsize::new(3).unwrap()).unwrap();
558        let parallel = SharedPoolMorselScheduler::new(pool)
559            .execute(&admission, |morsel| {
560                std::thread::yield_now();
561                Ok((morsel.ordinal.0, morsel.start_row, morsel.row_count))
562            })
563            .unwrap();
564
565        assert_eq!(parallel, sequential);
566    }
567
568    #[test]
569    fn shared_pool_scheduler_converts_worker_panics_to_stable_errors() {
570        let admission = admit_morsels(request(130)).unwrap();
571        let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
572        let error = SharedPoolMorselScheduler::new(pool)
573            .execute(&admission, |morsel| -> Result<u64> {
574                assert_ne!(morsel.ordinal.0, 1, "injected worker panic");
575                Ok(morsel.ordinal.0)
576            })
577            .unwrap_err();
578
579        assert!(error.to_string().contains("ordinal 1"), "{error}");
580    }
581
582    #[test]
583    fn shared_pool_scheduler_observes_cancellation_between_morsels() {
584        let admission = admit_morsels(request(1025)).unwrap();
585        let pool = SharedExecutorPool::new(NonZeroUsize::MIN).unwrap();
586        let token = hawdb_core::RuntimeCancellationToken::new();
587        let context = RuntimeTaskContext::without_deadline(token.clone());
588        let error = SharedPoolMorselScheduler::new(pool)
589            .execute_with_context(&admission, &context, |morsel| {
590                if morsel.ordinal.0 == 0 {
591                    token.cancel();
592                }
593                Ok(morsel.ordinal.0)
594            })
595            .unwrap_err();
596
597        assert!(error.to_string().contains("cancelled"));
598    }
599
600    #[test]
601    fn accounted_stream_preserves_order_and_bounds_completed_outputs() {
602        let admission = admit_morsels(request(320)).unwrap();
603        let pool = SharedExecutorPool::new(NonZeroUsize::new(3).unwrap()).unwrap();
604        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
605        let account = ledger.account(
606            QueryMemoryClass::MorselOutput,
607            "test reorder",
608            NonZeroUsize::new(96).unwrap(),
609        );
610        let later_completed = AtomicUsize::new(0);
611        let mut ordinals = Vec::new();
612
613        let report = SharedPoolMorselScheduler::new(pool)
614            .execute_accounted_ordered(
615                &admission,
616                MorselStreamResources {
617                    task_context: None,
618                    output_account: &account,
619                    output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
620                },
621                |morsel| {
622                    if morsel.ordinal.0 == 0 {
623                        while later_completed.load(Ordering::Acquire) < 2 {
624                            std::thread::yield_now();
625                        }
626                    } else if morsel.ordinal.0 <= 2 {
627                        later_completed.fetch_add(1, Ordering::Release);
628                    }
629                    Ok(MorselOutput::new(morsel.ordinal.0, 8))
630                },
631                |morsel, value| {
632                    assert_eq!(morsel.ordinal.0, value);
633                    ordinals.push(value);
634                    Ok(MorselStreamControl::Continue)
635                },
636            )
637            .unwrap();
638
639        assert_eq!(ordinals, vec![0, 1, 2, 3, 4]);
640        assert!((1..=admission.max_workers()).contains(&report.peak_buffered_outputs));
641        assert!(report.peak_reorder_entries <= admission.max_workers());
642        assert!(report.peak_buffered_output_bytes <= admission.max_workers() * 8);
643        assert_eq!(ledger.snapshot().used_bytes, 0);
644    }
645
646    #[test]
647    fn accounted_stream_reserves_before_worker_execution() {
648        let admission = admit_morsels(request(130)).unwrap();
649        let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
650        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
651        let account = ledger.account(
652            QueryMemoryClass::MorselOutput,
653            "test reservation",
654            NonZeroUsize::new(16).unwrap(),
655        );
656        let executed = AtomicUsize::new(0);
657
658        let error = SharedPoolMorselScheduler::new(pool)
659            .execute_accounted_ordered(
660                &admission,
661                MorselStreamResources {
662                    task_context: None,
663                    output_account: &account,
664                    output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
665                },
666                |morsel| {
667                    executed.fetch_add(1, Ordering::SeqCst);
668                    Ok(MorselOutput::new(morsel.ordinal.0, 8))
669                },
670                |_morsel, _value| Ok(MorselStreamControl::Continue),
671            )
672            .unwrap_err();
673
674        assert!(error.to_string().contains("16-byte budget"));
675        assert_eq!(executed.load(Ordering::SeqCst), 0);
676        assert_eq!(ledger.snapshot().used_bytes, 0);
677    }
678
679    #[test]
680    fn accounted_stream_releases_outputs_after_worker_panic() {
681        let admission = admit_morsels(request(130)).unwrap();
682        let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
683        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
684        let account = ledger.account(
685            QueryMemoryClass::MorselOutput,
686            "test panic",
687            NonZeroUsize::new(64).unwrap(),
688        );
689
690        let error = SharedPoolMorselScheduler::new(pool)
691            .execute_accounted_ordered(
692                &admission,
693                MorselStreamResources {
694                    task_context: None,
695                    output_account: &account,
696                    output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
697                },
698                |morsel| {
699                    assert_ne!(morsel.ordinal.0, 1, "injected worker panic");
700                    Ok(MorselOutput::new(morsel.ordinal.0, 8))
701                },
702                |_morsel, _value| Ok(MorselStreamControl::Continue),
703            )
704            .unwrap_err();
705
706        assert!(error.to_string().contains("ordinal 1"), "{error}");
707        assert_eq!(ledger.snapshot().used_bytes, 0);
708    }
709
710    #[test]
711    fn accounted_stream_stop_does_not_open_another_issue_window() {
712        let admission = admit_morsels(request(640)).unwrap();
713        let pool = SharedExecutorPool::new(NonZeroUsize::new(3).unwrap()).unwrap();
714        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
715        let account = ledger.account(
716            QueryMemoryClass::MorselOutput,
717            "test stop",
718            NonZeroUsize::new(96).unwrap(),
719        );
720        let executed = AtomicUsize::new(0);
721
722        let report = SharedPoolMorselScheduler::new(pool)
723            .execute_accounted_ordered(
724                &admission,
725                MorselStreamResources {
726                    task_context: None,
727                    output_account: &account,
728                    output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
729                },
730                |morsel| {
731                    executed.fetch_add(1, Ordering::SeqCst);
732                    Ok(MorselOutput::new(morsel.ordinal.0, 8))
733                },
734                |_morsel, _value| Ok(MorselStreamControl::Stop),
735            )
736            .unwrap();
737
738        assert!(report.stopped_early);
739        assert!(executed.load(Ordering::SeqCst) <= admission.max_workers());
740        assert_eq!(ledger.snapshot().used_bytes, 0);
741    }
742
743    #[test]
744    fn accounted_stream_converts_consumer_panics_and_releases_outputs() {
745        let admission = admit_morsels(request(130)).unwrap();
746        let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
747        let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
748        let account = ledger.account(
749            QueryMemoryClass::MorselOutput,
750            "test consumer panic",
751            NonZeroUsize::new(64).unwrap(),
752        );
753
754        let error = SharedPoolMorselScheduler::new(pool)
755            .execute_accounted_ordered(
756                &admission,
757                MorselStreamResources {
758                    task_context: None,
759                    output_account: &account,
760                    output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
761                },
762                |morsel| Ok(MorselOutput::new(morsel.ordinal.0, 8)),
763                |morsel, _value| -> Result<MorselStreamControl> {
764                    assert_ne!(morsel.ordinal.0, 0, "injected consumer panic");
765                    Ok(MorselStreamControl::Continue)
766                },
767            )
768            .unwrap_err();
769
770        assert!(error.to_string().contains("consumer panicked at ordinal 0"));
771        assert_eq!(ledger.snapshot().used_bytes, 0);
772    }
773}