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, 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#[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>(
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 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
478pub 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}