1use 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;
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#[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 pub fn execute_with_context<T, F>(
226 &self,
227 admission: &MorselAdmission,
228 context: &RuntimeTaskContext,
229 execute: F,
230 ) -> Result<Vec<T>>
231 where
232 T: Send,
233 F: Fn(Morsel) -> Result<T> + Sync,
234 {
235 if admission.morsel_count() == 0 {
236 context.checkpoint().map_err(|reason| {
237 HawDBError::Execution(format!("runtime task stopped: {reason}"))
238 })?;
239 return Ok(Vec::new());
240 }
241 let max_workers = NonZeroUsize::new(admission.max_workers()).ok_or_else(|| {
242 HawDBError::Execution("non-empty morsel admission reserved no workers".to_string())
243 })?;
244 let morsels = admission.morsels().collect::<Vec<_>>();
245 BoundedExecutor::with_pool(max_workers, self.pool.clone())
246 .map_ordered_with_context(&morsels, context, |morsel| {
247 execute_catching_panic(&execute, *morsel)
248 })
249 .map_err(|reason| HawDBError::Execution(format!("runtime task stopped: {reason}")))?
250 .into_iter()
251 .collect()
252 }
253
254 pub fn execute_accounted_ordered<T, F, C>(
262 &self,
263 admission: &MorselAdmission,
264 resources: MorselStreamResources<'_>,
265 execute: F,
266 mut consume: C,
267 ) -> Result<MorselStreamReport>
268 where
269 T: Send,
270 F: Fn(Morsel) -> Result<MorselOutput<T>> + Sync,
271 C: FnMut(Morsel, T) -> Result<MorselStreamControl>,
272 {
273 if admission.morsel_count() == 0 {
274 if let Some(context) = resources.task_context {
275 context.checkpoint().map_err(|reason| {
276 HawDBError::Execution(format!("runtime task stopped: {reason}"))
277 })?;
278 }
279 return Ok(MorselStreamReport::default());
280 }
281 let max_workers = NonZeroUsize::new(admission.max_workers()).ok_or_else(|| {
282 HawDBError::Execution("non-empty morsel admission reserved no workers".to_string())
283 })?;
284 let counters = Arc::new(MorselOutputCounters::default());
285 let concurrent_report = BoundedExecutor::with_pool(max_workers, self.pool.clone())
286 .try_for_each_index_ordered(
287 admission.morsel_count(),
288 resources.task_context,
289 |index| {
290 let morsel = admission
291 .morsel_at(index)
292 .expect("admitted morsel index is in range");
293 let mut lease = resources
294 .output_account
295 .reserve(resources.output_reservation_bytes.get())?;
296 let output = execute_catching_panic(&execute, morsel)?;
297 if output.resident_bytes > resources.output_reservation_bytes.get() {
298 return Err(HawDBError::Execution(format!(
299 "morsel pipeline {} output at ordinal {} uses {} bytes, exceeding its {}-byte reservation",
300 morsel.pipeline_id.0,
301 morsel.ordinal.0,
302 output.resident_bytes,
303 resources.output_reservation_bytes
304 )));
305 }
306 lease.shrink(
307 resources.output_reservation_bytes.get() - output.resident_bytes,
308 );
309 Ok(PendingMorselOutput::new(
310 output.value,
311 output.resident_bytes,
312 lease,
313 Arc::clone(&counters),
314 ))
315 },
316 |index, mut output| {
317 let morsel = admission
318 .morsel_at(index)
319 .expect("admitted morsel index is in range");
320 let value = output.take_value();
321 let control = catch_unwind(AssertUnwindSafe(|| consume(morsel, value)))
322 .unwrap_or_else(|_| {
323 Err(HawDBError::Execution(format!(
324 "morsel pipeline {} consumer panicked at ordinal {}",
325 morsel.pipeline_id.0, morsel.ordinal.0
326 )))
327 })?;
328 Ok(match control {
329 MorselStreamControl::Continue => BoundedOrderedStreamControl::Continue,
330 MorselStreamControl::Stop => BoundedOrderedStreamControl::Stop,
331 })
332 },
333 )?;
334 Ok(MorselStreamReport::from_parts(concurrent_report, &counters))
335 }
336}
337
338#[derive(Debug, Clone, Copy)]
339pub struct MorselStreamResources<'a> {
340 pub task_context: Option<&'a RuntimeTaskContext>,
341 pub output_account: &'a QueryMemoryAccount,
342 pub output_reservation_bytes: NonZeroUsize,
343}
344
345#[derive(Debug)]
346pub struct MorselOutput<T> {
347 value: T,
348 resident_bytes: usize,
349}
350
351impl<T> MorselOutput<T> {
352 pub fn new(value: T, resident_bytes: usize) -> Self {
353 Self {
354 value,
355 resident_bytes,
356 }
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum MorselStreamControl {
362 Continue,
363 Stop,
364}
365
366#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
367pub struct MorselStreamReport {
368 pub peak_buffered_outputs: usize,
369 pub peak_buffered_output_bytes: usize,
370 pub peak_reorder_entries: usize,
371 pub stopped_early: bool,
372}
373
374impl MorselStreamReport {
375 fn from_parts(concurrent: BoundedOrderedStreamReport, counters: &MorselOutputCounters) -> Self {
376 Self {
377 peak_buffered_outputs: counters.peak_outputs.load(Ordering::Relaxed),
378 peak_buffered_output_bytes: counters.peak_bytes.load(Ordering::Relaxed),
379 peak_reorder_entries: concurrent.peak_reorder_entries,
380 stopped_early: concurrent.stopped_early,
381 }
382 }
383}
384
385#[derive(Debug, Default)]
386struct MorselOutputCounters {
387 current_outputs: AtomicUsize,
388 peak_outputs: AtomicUsize,
389 current_bytes: AtomicUsize,
390 peak_bytes: AtomicUsize,
391}
392
393struct PendingMorselOutput<T> {
394 value: Option<T>,
395 resident_bytes: usize,
396 _lease: QueryMemoryLease,
397 counters: Arc<MorselOutputCounters>,
398}
399
400impl<T> PendingMorselOutput<T> {
401 fn new(
402 value: T,
403 resident_bytes: usize,
404 lease: QueryMemoryLease,
405 counters: Arc<MorselOutputCounters>,
406 ) -> Self {
407 let outputs = counters.current_outputs.fetch_add(1, Ordering::Relaxed) + 1;
408 counters.peak_outputs.fetch_max(outputs, Ordering::Relaxed);
409 let bytes = counters
410 .current_bytes
411 .fetch_add(resident_bytes, Ordering::Relaxed)
412 .saturating_add(resident_bytes);
413 counters.peak_bytes.fetch_max(bytes, Ordering::Relaxed);
414 Self {
415 value: Some(value),
416 resident_bytes,
417 _lease: lease,
418 counters,
419 }
420 }
421
422 fn take_value(&mut self) -> T {
423 self.value
424 .take()
425 .expect("pending morsel output value is consumed once")
426 }
427}
428
429impl<T> Drop for PendingMorselOutput<T> {
430 fn drop(&mut self) {
431 self.counters
432 .current_outputs
433 .fetch_sub(1, Ordering::Relaxed);
434 self.counters
435 .current_bytes
436 .fetch_sub(self.resident_bytes, Ordering::Relaxed);
437 }
438}
439
440fn execute_catching_panic<T>(
441 execute: &(impl Fn(Morsel) -> Result<T> + Sync),
442 morsel: Morsel,
443) -> Result<T> {
444 catch_unwind(AssertUnwindSafe(|| execute(morsel))).unwrap_or_else(|_| {
445 Err(HawDBError::Execution(format!(
446 "morsel pipeline {} worker panicked at ordinal {}",
447 morsel.pipeline_id.0, morsel.ordinal.0
448 )))
449 })
450}
451
452pub fn execute_morsels_ordered<T>(
460 admission: &MorselAdmission,
461 mut execute: impl FnMut(Morsel) -> Result<T>,
462) -> Result<Vec<T>> {
463 SequentialMorselScheduler.execute(admission, &mut execute)
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::{QueryMemoryClass, QueryMemoryLedger};
470
471 fn request(input_rows: usize) -> MorselAdmissionRequest {
472 MorselAdmissionRequest {
473 pipeline_id: PipelineId(7),
474 input_rows,
475 target_rows: NonZeroUsize::new(64).unwrap(),
476 requested_parallelism: NonZeroUsize::new(8).unwrap(),
477 bytes_per_worker: NonZeroUsize::new(1024).unwrap(),
478 memory_budget_bytes: NonZeroUsize::new(3 * 1024).unwrap(),
479 }
480 }
481
482 #[test]
483 fn admission_is_bounded_by_work_and_memory() {
484 let admission = admit_morsels(request(130)).unwrap();
485
486 assert_eq!(admission.max_workers(), 3);
487 assert_eq!(admission.reserved_bytes(), 3 * 1024);
488 assert_eq!(
489 admission
490 .morsels()
491 .map(|morsel| (morsel.start_row, morsel.row_count))
492 .collect::<Vec<_>>(),
493 vec![(0, 64), (64, 64), (128, 2)]
494 );
495 }
496
497 #[test]
498 fn admission_rejects_a_worker_that_cannot_fit() {
499 let mut request = request(1);
500 request.bytes_per_worker = NonZeroUsize::new(4096).unwrap();
501
502 assert!(admit_morsels(request).is_err());
503 }
504
505 #[test]
506 fn ordered_executor_preserves_morsel_ordinals() {
507 let admission = MorselAdmission::try_new(request(130)).unwrap();
508 let ordinals = SequentialMorselScheduler
509 .execute(&admission, |morsel| Ok(morsel.ordinal.0))
510 .unwrap();
511
512 assert_eq!(ordinals, vec![0, 1, 2]);
513 }
514
515 #[test]
516 fn empty_input_reserves_no_workers() {
517 let admission = admit_morsels(request(0)).unwrap();
518
519 assert_eq!(admission.morsel_count(), 0);
520 assert_eq!(admission.max_workers(), 0);
521 assert_eq!(admission.reserved_bytes(), 0);
522 }
523
524 #[test]
525 fn shared_pool_scheduler_matches_sequential_ordinal_order() {
526 let admission = admit_morsels(request(1025)).unwrap();
527 let sequential = execute_morsels_ordered(&admission, |morsel| {
528 Ok((morsel.ordinal.0, morsel.start_row, morsel.row_count))
529 })
530 .unwrap();
531 let pool = SharedExecutorPool::new(NonZeroUsize::new(3).unwrap()).unwrap();
532 let parallel = SharedPoolMorselScheduler::new(pool)
533 .execute(&admission, |morsel| {
534 std::thread::yield_now();
535 Ok((morsel.ordinal.0, morsel.start_row, morsel.row_count))
536 })
537 .unwrap();
538
539 assert_eq!(parallel, sequential);
540 }
541
542 #[test]
543 fn shared_pool_scheduler_converts_worker_panics_to_stable_errors() {
544 let admission = admit_morsels(request(130)).unwrap();
545 let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
546 let error = SharedPoolMorselScheduler::new(pool)
547 .execute(&admission, |morsel| -> Result<u64> {
548 assert_ne!(morsel.ordinal.0, 1, "injected worker panic");
549 Ok(morsel.ordinal.0)
550 })
551 .unwrap_err();
552
553 assert!(error.to_string().contains("ordinal 1"), "{error}");
554 }
555
556 #[test]
557 fn shared_pool_scheduler_observes_cancellation_between_morsels() {
558 let admission = admit_morsels(request(1025)).unwrap();
559 let pool = SharedExecutorPool::new(NonZeroUsize::MIN).unwrap();
560 let token = hawdb_core::RuntimeCancellationToken::new();
561 let context = RuntimeTaskContext::without_deadline(token.clone());
562 let error = SharedPoolMorselScheduler::new(pool)
563 .execute_with_context(&admission, &context, |morsel| {
564 if morsel.ordinal.0 == 0 {
565 token.cancel();
566 }
567 Ok(morsel.ordinal.0)
568 })
569 .unwrap_err();
570
571 assert!(error.to_string().contains("cancelled"));
572 }
573
574 #[test]
575 fn accounted_stream_preserves_order_and_bounds_completed_outputs() {
576 let admission = admit_morsels(request(320)).unwrap();
577 let pool = SharedExecutorPool::new(NonZeroUsize::new(3).unwrap()).unwrap();
578 let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
579 let account = ledger.account(
580 QueryMemoryClass::MorselOutput,
581 "test reorder",
582 NonZeroUsize::new(96).unwrap(),
583 );
584 let later_completed = AtomicUsize::new(0);
585 let mut ordinals = Vec::new();
586
587 let report = SharedPoolMorselScheduler::new(pool)
588 .execute_accounted_ordered(
589 &admission,
590 MorselStreamResources {
591 task_context: None,
592 output_account: &account,
593 output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
594 },
595 |morsel| {
596 if morsel.ordinal.0 == 0 {
597 while later_completed.load(Ordering::Acquire) < 2 {
598 std::thread::yield_now();
599 }
600 } else if morsel.ordinal.0 <= 2 {
601 later_completed.fetch_add(1, Ordering::Release);
602 }
603 Ok(MorselOutput::new(morsel.ordinal.0, 8))
604 },
605 |morsel, value| {
606 assert_eq!(morsel.ordinal.0, value);
607 ordinals.push(value);
608 Ok(MorselStreamControl::Continue)
609 },
610 )
611 .unwrap();
612
613 assert_eq!(ordinals, vec![0, 1, 2, 3, 4]);
614 assert!((1..=admission.max_workers()).contains(&report.peak_buffered_outputs));
615 assert!(report.peak_reorder_entries <= admission.max_workers());
616 assert!(report.peak_buffered_output_bytes <= admission.max_workers() * 8);
617 assert_eq!(ledger.snapshot().used_bytes, 0);
618 }
619
620 #[test]
621 fn accounted_stream_reserves_before_worker_execution() {
622 let admission = admit_morsels(request(130)).unwrap();
623 let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
624 let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
625 let account = ledger.account(
626 QueryMemoryClass::MorselOutput,
627 "test reservation",
628 NonZeroUsize::new(16).unwrap(),
629 );
630 let executed = AtomicUsize::new(0);
631
632 let error = SharedPoolMorselScheduler::new(pool)
633 .execute_accounted_ordered(
634 &admission,
635 MorselStreamResources {
636 task_context: None,
637 output_account: &account,
638 output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
639 },
640 |morsel| {
641 executed.fetch_add(1, Ordering::SeqCst);
642 Ok(MorselOutput::new(morsel.ordinal.0, 8))
643 },
644 |_morsel, _value| Ok(MorselStreamControl::Continue),
645 )
646 .unwrap_err();
647
648 assert!(error.to_string().contains("16-byte budget"));
649 assert_eq!(executed.load(Ordering::SeqCst), 0);
650 assert_eq!(ledger.snapshot().used_bytes, 0);
651 }
652
653 #[test]
654 fn accounted_stream_releases_outputs_after_worker_panic() {
655 let admission = admit_morsels(request(130)).unwrap();
656 let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
657 let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
658 let account = ledger.account(
659 QueryMemoryClass::MorselOutput,
660 "test panic",
661 NonZeroUsize::new(64).unwrap(),
662 );
663
664 let error = SharedPoolMorselScheduler::new(pool)
665 .execute_accounted_ordered(
666 &admission,
667 MorselStreamResources {
668 task_context: None,
669 output_account: &account,
670 output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
671 },
672 |morsel| {
673 assert_ne!(morsel.ordinal.0, 1, "injected worker panic");
674 Ok(MorselOutput::new(morsel.ordinal.0, 8))
675 },
676 |_morsel, _value| Ok(MorselStreamControl::Continue),
677 )
678 .unwrap_err();
679
680 assert!(error.to_string().contains("ordinal 1"), "{error}");
681 assert_eq!(ledger.snapshot().used_bytes, 0);
682 }
683
684 #[test]
685 fn accounted_stream_stop_does_not_open_another_issue_window() {
686 let admission = admit_morsels(request(640)).unwrap();
687 let pool = SharedExecutorPool::new(NonZeroUsize::new(3).unwrap()).unwrap();
688 let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
689 let account = ledger.account(
690 QueryMemoryClass::MorselOutput,
691 "test stop",
692 NonZeroUsize::new(96).unwrap(),
693 );
694 let executed = AtomicUsize::new(0);
695
696 let report = SharedPoolMorselScheduler::new(pool)
697 .execute_accounted_ordered(
698 &admission,
699 MorselStreamResources {
700 task_context: None,
701 output_account: &account,
702 output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
703 },
704 |morsel| {
705 executed.fetch_add(1, Ordering::SeqCst);
706 Ok(MorselOutput::new(morsel.ordinal.0, 8))
707 },
708 |_morsel, _value| Ok(MorselStreamControl::Stop),
709 )
710 .unwrap();
711
712 assert!(report.stopped_early);
713 assert!(executed.load(Ordering::SeqCst) <= admission.max_workers());
714 assert_eq!(ledger.snapshot().used_bytes, 0);
715 }
716
717 #[test]
718 fn accounted_stream_converts_consumer_panics_and_releases_outputs() {
719 let admission = admit_morsels(request(130)).unwrap();
720 let pool = SharedExecutorPool::new(NonZeroUsize::new(2).unwrap()).unwrap();
721 let ledger = QueryMemoryLedger::new(NonZeroUsize::new(1024).unwrap());
722 let account = ledger.account(
723 QueryMemoryClass::MorselOutput,
724 "test consumer panic",
725 NonZeroUsize::new(64).unwrap(),
726 );
727
728 let error = SharedPoolMorselScheduler::new(pool)
729 .execute_accounted_ordered(
730 &admission,
731 MorselStreamResources {
732 task_context: None,
733 output_account: &account,
734 output_reservation_bytes: NonZeroUsize::new(32).unwrap(),
735 },
736 |morsel| Ok(MorselOutput::new(morsel.ordinal.0, 8)),
737 |morsel, _value| -> Result<MorselStreamControl> {
738 assert_ne!(morsel.ordinal.0, 0, "injected consumer panic");
739 Ok(MorselStreamControl::Continue)
740 },
741 )
742 .unwrap_err();
743
744 assert!(error.to_string().contains("consumer panicked at ordinal 0"));
745 assert_eq!(ledger.snapshot().used_bytes, 0);
746 }
747}