Skip to main content

sui_futures/
stream.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::future::Future;
5use std::future::poll_fn;
6use std::panic;
7use std::pin::pin;
8use std::sync::Arc;
9
10use futures::FutureExt;
11use futures::future::try_join_all;
12use futures::stream::Stream;
13use futures::try_join;
14use tokio::sync::mpsc;
15use tokio::task::JoinSet;
16
17/// Runtime configuration for adaptive concurrency control.
18///
19/// For fixed concurrency, use [`ConcurrencyConfig::fixed`].
20/// For adaptive concurrency, use [`ConcurrencyConfig::adaptive`] which requires the three core
21/// parameters (initial, min, max). The dead-band thresholds have sensible defaults and can be
22/// overridden with a chainable setter:
23///
24/// ```ignore
25/// ConcurrencyConfig::adaptive(10, 1, 20)
26///     .with_dead_band(0.5, 0.9)
27/// ```
28#[derive(Debug, Clone)]
29pub struct ConcurrencyConfig {
30    pub initial: usize,
31    pub min: usize,
32    pub max: usize,
33    /// Fill fraction below which the controller may increase the limit (if saturated).
34    /// Default: 0.6.
35    pub dead_band_low: f64,
36    /// Fill fraction at or above which the controller decreases the limit. Default: 0.85.
37    pub dead_band_high: f64,
38}
39
40/// Snapshot of concurrency stats passed to the `report` callback.
41#[derive(Debug, Clone, Copy)]
42pub struct ConcurrencyStats {
43    pub limit: usize,
44    pub inflight: usize,
45}
46
47/// Wrapper type for errors to allow the body of a `try_for_each_spawned` call to signal that it
48/// either wants to return early (`Break`) out of the loop, or propagate an error (`Err(E)`).
49#[derive(Debug)]
50pub enum Break<E> {
51    Break,
52    Err(E),
53}
54
55/// Extension trait introducing `try_for_each_spawned` to all streams.
56pub trait TrySpawnStreamExt: Stream {
57    /// Attempts to run this stream to completion, executing the provided asynchronous closure on
58    /// each element from the stream as elements become available.
59    ///
60    /// This is similar to [`futures::stream::StreamExt::for_each_concurrent`], but it may take advantage of any
61    /// parallelism available in the underlying runtime, because each unit of work is spawned as
62    /// its own tokio task.
63    ///
64    /// The first argument is an optional limit on the number of tasks to spawn concurrently.
65    /// Values of `0` and `None` are interpreted as no limit, and any other value will result in no
66    /// more than that many tasks being spawned at one time.
67    ///
68    /// ## Safety
69    ///
70    /// This function will panic if any of its futures panics, will return early with success if
71    /// the runtime it is running on is cancelled, and will return early with an error propagated
72    /// from any worker that produces an error.
73    fn try_for_each_spawned<Fut, F, E>(
74        self,
75        limit: impl Into<Option<usize>>,
76        f: F,
77    ) -> impl Future<Output = Result<(), E>>
78    where
79        Fut: Future<Output = Result<(), E>> + Send + 'static,
80        F: FnMut(Self::Item) -> Fut,
81        E: Send + 'static;
82
83    /// Process each stream item through a spawned task, sending results to a single channel.
84    ///
85    /// Each item is passed to `f` which returns a future producing `Result<T, Break<E>>`. The
86    /// resulting `T` is sent to `tx`. Concurrency is controlled by `config`: for fixed configs,
87    /// the limit never changes; for adaptive configs, the limit adjusts based on the fill fraction
88    /// of the output channel.
89    ///
90    /// Unlike [`try_for_each_broadcast_spawned`](TrySpawnStreamExt::try_for_each_broadcast_spawned),
91    /// `T` does not need to be `Clone` since there is only a single receiver.
92    ///
93    /// The `report` callback is invoked each iteration with concurrency stats for metrics.
94    fn try_for_each_send_spawned<Fut, F, T, E, R>(
95        self,
96        config: ConcurrencyConfig,
97        f: F,
98        tx: mpsc::Sender<T>,
99        report: R,
100    ) -> impl Future<Output = Result<(), Break<E>>>
101    where
102        Fut: Future<Output = Result<T, Break<E>>> + Send + 'static,
103        F: FnMut(Self::Item) -> Fut,
104        T: Send + 'static,
105        E: Send + 'static,
106        R: Fn(ConcurrencyStats);
107
108    /// Process each stream item through a spawned task, broadcasting results to multiple channels.
109    ///
110    /// Same as [`try_for_each_send_spawned`](TrySpawnStreamExt::try_for_each_send_spawned) but
111    /// sends a clone of each result to every channel in `txs`. Fill fraction is measured as the
112    /// maximum across all channels. Requires `T: Clone` since values are cloned to each receiver.
113    fn try_for_each_broadcast_spawned<Fut, F, T, E, R>(
114        self,
115        config: ConcurrencyConfig,
116        f: F,
117        txs: Vec<mpsc::Sender<T>>,
118        report: R,
119    ) -> impl Future<Output = Result<(), Break<E>>>
120    where
121        Fut: Future<Output = Result<T, Break<E>>> + Send + 'static,
122        F: FnMut(Self::Item) -> Fut,
123        T: Clone + Send + Sync + 'static,
124        E: Send + 'static,
125        R: Fn(ConcurrencyStats);
126}
127
128/// Abstraction over single-channel and broadcast sending so that
129/// `adaptive_spawn_send` can be generic over both. This avoids requiring `T: Clone`
130/// on the single-sender path (`SingleSender` moves the value without cloning).
131///
132/// Implementors must be cheaply cloneable (they are cloned for each spawned task).
133trait Sender: Clone + Send + Sync + 'static {
134    type Value: Send + 'static;
135
136    /// Send a value downstream. Returns `Err(())` if the channel(s) are closed.
137    fn send(&self, value: Self::Value) -> impl Future<Output = Result<(), ()>> + Send;
138
139    /// Measure the fill fraction of the downstream channel(s).
140    /// Returns a value in [0.0, 1.0] where 1.0 means completely full.
141    fn fill(&self) -> f64;
142}
143
144/// Single-channel sender.
145struct SingleSender<T>(mpsc::Sender<T>);
146
147/// Broadcast sender that clones the value to all channels.
148struct BroadcastSender<T>(Arc<Vec<mpsc::Sender<T>>>);
149
150impl ConcurrencyConfig {
151    pub fn fixed(n: usize) -> Self {
152        Self {
153            initial: n,
154            min: n,
155            max: n,
156            dead_band_low: 0.6,
157            dead_band_high: 0.85,
158        }
159    }
160
161    pub fn adaptive(initial: usize, min: usize, max: usize) -> Self {
162        Self {
163            initial,
164            min,
165            max,
166            dead_band_low: 0.6,
167            dead_band_high: 0.85,
168        }
169    }
170
171    pub fn with_dead_band(mut self, low: f64, high: f64) -> Self {
172        self.dead_band_low = low;
173        self.dead_band_high = high;
174        self
175    }
176}
177
178impl<E> From<E> for Break<E> {
179    fn from(e: E) -> Self {
180        Break::Err(e)
181    }
182}
183
184impl<S: Stream + Sized + 'static> TrySpawnStreamExt for S {
185    async fn try_for_each_spawned<Fut, F, E>(
186        self,
187        limit: impl Into<Option<usize>>,
188        mut f: F,
189    ) -> Result<(), E>
190    where
191        Fut: Future<Output = Result<(), E>> + Send + 'static,
192        F: FnMut(Self::Item) -> Fut,
193        E: Send + 'static,
194    {
195        // Maximum number of tasks to spawn concurrently.
196        let limit = match limit.into() {
197            Some(0) | None => usize::MAX,
198            Some(n) => n,
199        };
200
201        // Number of permits to spawn tasks left.
202        let mut permits = limit;
203        // Handles for already spawned tasks.
204        let mut join_set = JoinSet::new();
205        // Whether the worker pool has stopped accepting new items and is draining.
206        let mut draining = false;
207        // Error that occurred in one of the workers, to be propagated to the called on exit.
208        let mut error = None;
209
210        let mut self_ = pin!(self);
211
212        loop {
213            // Eager inner loop: spawn tasks while permits allow and items are ready,
214            // avoiding select! overhead when items are immediately available.
215            while !draining && permits > 0 {
216                match poll_fn(|cx| self_.as_mut().poll_next(cx)).now_or_never() {
217                    Some(Some(item)) => {
218                        permits -= 1;
219                        join_set.spawn(f(item).boxed());
220                    }
221                    Some(None) => {
222                        // If the stream is empty, signal that the worker pool is going to
223                        // start draining now, so that once we get all our permits back, we
224                        // know we can wind down the pool.
225                        draining = true;
226                    }
227                    None => break,
228                }
229            }
230
231            tokio::select! {
232                biased;
233
234                Some(res) = join_set.join_next() => {
235                    match res {
236                        Ok(Err(e)) if error.is_none() => {
237                            error = Some(e);
238                            permits += 1;
239                            draining = true;
240                        }
241
242                        Ok(_) => permits += 1,
243
244                        // Worker panicked, propagate the panic.
245                        Err(e) if e.is_panic() => {
246                            panic::resume_unwind(e.into_panic())
247                        }
248
249                        // Worker was cancelled -- this can only happen if its join handle was
250                        // cancelled (not possible because that was created in this function),
251                        // or the runtime it was running in was wound down, in which case,
252                        // prepare the worker pool to drain.
253                        Err(e) => {
254                            assert!(e.is_cancelled());
255                            permits += 1;
256                            draining = true;
257                        }
258                    }
259                }
260
261                next = poll_fn(|cx| self_.as_mut().poll_next(cx)),
262                    if !draining && permits > 0 => {
263                    if let Some(item) = next {
264                        permits -= 1;
265                        join_set.spawn(f(item).boxed());
266                    } else {
267                        draining = true;
268                    }
269                }
270
271                else => {
272                    if permits == limit && draining {
273                        break;
274                    }
275                }
276            }
277        }
278
279        if let Some(e) = error { Err(e) } else { Ok(()) }
280    }
281
282    async fn try_for_each_send_spawned<Fut, F, T, E, R>(
283        self,
284        config: ConcurrencyConfig,
285        f: F,
286        tx: mpsc::Sender<T>,
287        report: R,
288    ) -> Result<(), Break<E>>
289    where
290        Fut: Future<Output = Result<T, Break<E>>> + Send + 'static,
291        F: FnMut(Self::Item) -> Fut,
292        T: Send + 'static,
293        E: Send + 'static,
294        R: Fn(ConcurrencyStats),
295    {
296        adaptive_spawn_send(self, config, f, SingleSender(tx), report).await
297    }
298
299    async fn try_for_each_broadcast_spawned<Fut, F, T, E, R>(
300        self,
301        config: ConcurrencyConfig,
302        f: F,
303        txs: Vec<mpsc::Sender<T>>,
304        report: R,
305    ) -> Result<(), Break<E>>
306    where
307        Fut: Future<Output = Result<T, Break<E>>> + Send + 'static,
308        F: FnMut(Self::Item) -> Fut,
309        T: Clone + Send + Sync + 'static,
310        E: Send + 'static,
311        R: Fn(ConcurrencyStats),
312    {
313        adaptive_spawn_send(self, config, f, BroadcastSender(Arc::new(txs)), report).await
314    }
315}
316
317impl<T> Clone for SingleSender<T> {
318    fn clone(&self) -> Self {
319        Self(self.0.clone())
320    }
321}
322
323impl<T: Send + 'static> Sender for SingleSender<T> {
324    type Value = T;
325
326    async fn send(&self, value: T) -> Result<(), ()> {
327        self.0.send(value).await.map_err(|_| ())
328    }
329
330    fn fill(&self) -> f64 {
331        1.0 - (self.0.capacity() as f64 / self.0.max_capacity() as f64)
332    }
333}
334
335impl<T> Clone for BroadcastSender<T> {
336    fn clone(&self) -> Self {
337        Self(self.0.clone())
338    }
339}
340
341impl<T: Clone + Send + Sync + 'static> Sender for BroadcastSender<T> {
342    type Value = T;
343
344    async fn send(&self, value: T) -> Result<(), ()> {
345        let (last, rest) = self.0.split_last().ok_or(())?;
346        let rest_fut = try_join_all(rest.iter().map(|tx| {
347            let v = value.clone();
348            async move { tx.send(v).await.map_err(|_| ()) }
349        }));
350        let last_fut = last.send(value).map(|r| r.map_err(|_| ()));
351        try_join!(rest_fut, last_fut)?;
352        Ok(())
353    }
354
355    fn fill(&self) -> f64 {
356        self.0
357            .iter()
358            .map(|tx| 1.0 - (tx.capacity() as f64 / tx.max_capacity() as f64))
359            .fold(0.0f64, f64::max)
360    }
361}
362
363/// Shared adaptive concurrency loop used by both `try_for_each_send_spawned` and
364/// `try_for_each_broadcast_spawned`.
365///
366/// The algorithm is a result of weeks of trial and error guided by lots of Claude Research queries.
367/// I tried to document where each of the ideas came from in the footnotes of this comment.
368///
369/// # Adaptive concurrency control
370///
371/// When `config.min < config.max`, this function dynamically adjusts how many tasks run
372/// concurrently based on how full the downstream channel(s) are. The goal is to find and
373/// hold a stable concurrency limit that keeps downstream fed without using excessive memory.
374///
375/// ## The core problem
376///
377/// N tasks share one set of output channels. If we naively cut the limit every time any task
378/// sees a full channel, all N tasks observe the same fullness at once and each triggers a
379/// cut — collapsing the limit exponentially (`limit * ratio^N`) instead of once. This causes
380/// wild oscillation: the limit crashes to near-zero then slowly climbs back, wasting ~50%
381/// of throughput.
382///
383/// ## How this controller avoids that
384///
385/// **Epoch-gated reductions** — Each task captures the current `epoch` when spawned. A
386/// reduction increments the epoch. If a completing task's epoch is stale (from before the
387/// last reduction), its congestion signal is ignored. This ensures only one reduction fires
388/// per congestion event, no matter how many tasks see it. [1]
389///
390/// **Severity-scaled cuts** — Instead of "full → cut by fixed ratio", a `severity` score
391/// derived from the fill fraction drives the size of the cut linearly:
392/// `keep = 0.8 - 0.3 * severity` where `severity = (fill - 0.85) / 0.15`.
393/// At fill = 0.85 (threshold), severity = 0 and the controller keeps 80% (gentle 20%
394/// cut). At fill = 1.0 (hard saturation), severity = 1 and the controller keeps 50%
395/// (aggressive halving). [2]
396///
397/// **Guaranteed progress** — After computing the proportional cut, the new limit is capped
398/// at `limit - 1` so that `ceil()` rounding cannot stall decreases at small values.
399///
400/// **Dead band between increase and decrease** — Three zones based on fill fraction:
401/// - `fill >= 0.85`: decrease (proportionally, epoch-gated)
402/// - `fill < 0.60`: increase (if the limit was actually being used)
403/// - `0.60–0.85`: do nothing
404///
405/// The gap between the increase and decrease thresholds prevents the controller from
406/// endlessly flip-flopping at the boundary — it finds a stable operating point and stays
407/// there. [3]
408///
409/// **Log10-scaled increase** — The limit grows by `ceil(log10(limit))` instead of +1. This
410/// scales sub-linearly: recovery isn't painfully slow at high limits (e.g. +3 at limit=200
411/// vs +1), but it's slower than proportional growth so it doesn't overshoot. [4]
412///
413/// **Saturation guard** — The limit only increases when inflight actually reached the limit
414/// (`was_saturated`). Without this, the limit would grow unboundedly during low-load periods
415/// since fill stays low and every completion triggers an increase — even though the extra
416/// concurrency was never actually used or tested against real backpressure. When load
417/// eventually does arrive, the inflated limit allows a burst that overwhelms the channel.
418/// Crucially, `was_saturated` resets on *both* increase and decrease. The decrease-side
419/// reset acts as a cooling-off period: after cutting the limit, the controller must see
420/// the new lower limit fully utilized before raising it again. Without this, the old
421/// saturation proof carries over, and the increase branch fires on the very next low-fill
422/// completion — undoing the decrease and causing oscillation.
423///
424/// ---
425/// [1] Same idea as TCP NewReno's `recover` variable (RFC 6582): record a high-water mark
426///     when congestion is detected, suppress further reductions until tasks past that mark
427///     start completing.
428/// [2] Analogous to DCTCP's proportional window reduction (RFC 8257):
429///     `cwnd = cwnd * (1 - α/2)` where α is the marked fraction.
430/// [3] TCP Vegas uses the same approach with alpha/beta thresholds around estimated queue
431///     depth to avoid oscillation at the operating point.
432/// [4] Netflix's `concurrency-limits` VegasLimit uses the same `log10(limit)` additive
433///     increase with a `max(1)` floor.
434async fn adaptive_spawn_send<S, Fut, F, E, Tx, R>(
435    stream: S,
436    config: ConcurrencyConfig,
437    mut f: F,
438    sender: Tx,
439    report: R,
440) -> Result<(), Break<E>>
441where
442    S: Stream + 'static,
443    Fut: Future<Output = Result<Tx::Value, Break<E>>> + Send + 'static,
444    F: FnMut(S::Item) -> Fut,
445    E: Send + 'static,
446    Tx: Sender,
447    R: Fn(ConcurrencyStats),
448{
449    assert!(config.min >= 1, "ConcurrencyConfig::min must be >= 1");
450    let mut limit = config.initial;
451    let mut epoch: u64 = 0;
452    let mut was_saturated = false;
453    let mut tasks: JoinSet<Result<u64, Break<E>>> = JoinSet::new();
454    let mut stream_done = false;
455    let mut error: Option<Break<E>> = None;
456
457    let mut stream = pin!(stream);
458
459    loop {
460        if tasks.is_empty() && (stream_done || error.is_some()) {
461            break;
462        }
463
464        // Eager inner loop: spawn tasks while under the limit and items are ready.
465        while tasks.len() < limit && !stream_done && error.is_none() {
466            match poll_fn(|cx| stream.as_mut().poll_next(cx)).now_or_never() {
467                Some(Some(item)) => {
468                    let fut = f(item);
469                    let tx = sender.clone();
470                    let spawn_epoch = epoch;
471                    tasks.spawn(
472                        async move {
473                            let value = fut.await?;
474                            tx.send(value).await.map_err(|_| Break::Break)?;
475                            Ok(spawn_epoch)
476                        }
477                        .boxed(),
478                    );
479                    if tasks.len() >= limit {
480                        was_saturated = true;
481                    }
482                }
483                Some(None) => stream_done = true,
484                None => break,
485            }
486        }
487
488        let completed = tokio::select! {
489            biased;
490
491            Some(r) = tasks.join_next(), if !tasks.is_empty() => Some(r),
492
493            next = poll_fn(|cx| stream.as_mut().poll_next(cx)),
494                if tasks.len() < limit && !stream_done && error.is_none() =>
495            {
496                if let Some(item) = next {
497                    let fut = f(item);
498                    let tx = sender.clone();
499                    let spawn_epoch = epoch;
500                    tasks.spawn(
501                        async move {
502                            let value = fut.await?;
503                            tx.send(value).await.map_err(|_| Break::Break)?;
504                            Ok(spawn_epoch)
505                        }
506                        .boxed(),
507                    );
508                    if tasks.len() >= limit {
509                        was_saturated = true;
510                    }
511                } else {
512                    stream_done = true;
513                }
514                None
515            }
516
517            else => {
518                if tasks.is_empty() && (stream_done || error.is_some()) {
519                    break;
520                }
521                None
522            }
523        };
524
525        // Handle all completions: the one from select (if any) + drain ready ones.
526        for join_result in completed.into_iter().chain(std::iter::from_fn(|| {
527            tasks.join_next().now_or_never().flatten()
528        })) {
529            match join_result {
530                Ok(Ok(spawn_epoch)) => {
531                    // Adjust concurrency limit based on channel fill fraction.
532                    // - fill >= dead_band_high: severity-scaled decrease (epoch-gated)
533                    // - fill < dead_band_low and limit was saturated: log10-scaled increase
534                    // - fill in [dead_band_low, dead_band_high): hold steady
535                    // When config is fixed (min == max), clamp/min keep limit unchanged.
536                    let fill = sender.fill();
537                    if fill >= config.dead_band_high && spawn_epoch == epoch {
538                        // Proportional cut that gets aggressive near saturation.
539                        // At dead_band_high: keep 80% (cut 20%)
540                        // At midpoint:       keep 65% (cut 35%)
541                        // At 1.00:           keep 50% (cut 50%)
542                        let severity =
543                            (fill - config.dead_band_high) / (1.0 - config.dead_band_high);
544                        let keep = 0.8 - 0.3 * severity;
545                        let new_limit = ((limit as f64) * keep).ceil() as usize;
546                        limit = new_limit.min(limit.saturating_sub(1)).max(config.min);
547                        limit = limit.clamp(config.min, config.max);
548                        epoch += 1;
549                        // Reset was_saturated so the controller must re-prove the new
550                        // lower limit is fully utilized before increasing again. This
551                        // cooling-off period prevents decrease→increase oscillation:
552                        // without it, the old saturation proof carries over and the
553                        // increase branch fires immediately on the next low-fill
554                        // completion, undoing the decrease.
555                        was_saturated = false;
556                    } else if fill < config.dead_band_low && was_saturated {
557                        let increment = ((limit as f64).log10().ceil() as usize).max(1);
558                        limit = (limit + increment).min(config.max);
559                        was_saturated = false;
560                    }
561                }
562                Ok(Err(e)) if error.is_none() => error = Some(e),
563                Ok(Err(_)) => {}
564                Err(e) if e.is_panic() => panic::resume_unwind(e.into_panic()),
565                Err(e) => {
566                    assert!(e.is_cancelled());
567                    stream_done = true;
568                }
569            }
570        }
571
572        report(ConcurrencyStats {
573            limit,
574            inflight: tasks.len(),
575        });
576    }
577
578    if let Some(e) = error { Err(e) } else { Ok(()) }
579}
580
581#[cfg(test)]
582mod tests {
583    use std::{
584        sync::{
585            Arc, Mutex,
586            atomic::{AtomicUsize, Ordering},
587        },
588        time::Duration,
589    };
590
591    use futures::stream;
592
593    use super::*;
594
595    #[tokio::test]
596    async fn for_each_explicit_sequential_iteration() {
597        let actual = Arc::new(Mutex::new(vec![]));
598        let result = stream::iter(0..20)
599            .try_for_each_spawned(1, |i| {
600                let actual = actual.clone();
601                async move {
602                    tokio::time::sleep(Duration::from_millis(20 - i)).await;
603                    actual.lock().unwrap().push(i);
604                    Ok::<(), ()>(())
605                }
606            })
607            .await;
608
609        assert!(result.is_ok());
610
611        let actual = Arc::try_unwrap(actual).unwrap().into_inner().unwrap();
612        let expect: Vec<_> = (0..20).collect();
613        assert_eq!(expect, actual);
614    }
615
616    #[tokio::test]
617    async fn for_each_concurrent_iteration() {
618        let actual = Arc::new(AtomicUsize::new(0));
619        let result = stream::iter(0..100)
620            .try_for_each_spawned(16, |i| {
621                let actual = actual.clone();
622                async move {
623                    actual.fetch_add(i, Ordering::Relaxed);
624                    Ok::<(), ()>(())
625                }
626            })
627            .await;
628
629        assert!(result.is_ok());
630
631        let actual = Arc::try_unwrap(actual).unwrap().into_inner();
632        let expect = 99 * 100 / 2;
633        assert_eq!(expect, actual);
634    }
635
636    #[tokio::test]
637    async fn for_each_implicit_unlimited_iteration() {
638        let actual = Arc::new(AtomicUsize::new(0));
639        let result = stream::iter(0..100)
640            .try_for_each_spawned(None, |i| {
641                let actual = actual.clone();
642                async move {
643                    actual.fetch_add(i, Ordering::Relaxed);
644                    Ok::<(), ()>(())
645                }
646            })
647            .await;
648
649        assert!(result.is_ok());
650
651        let actual = Arc::try_unwrap(actual).unwrap().into_inner();
652        let expect = 99 * 100 / 2;
653        assert_eq!(expect, actual);
654    }
655
656    #[tokio::test]
657    async fn for_each_explicit_unlimited_iteration() {
658        let actual = Arc::new(AtomicUsize::new(0));
659        let result = stream::iter(0..100)
660            .try_for_each_spawned(0, |i| {
661                let actual = actual.clone();
662                async move {
663                    actual.fetch_add(i, Ordering::Relaxed);
664                    Ok::<(), ()>(())
665                }
666            })
667            .await;
668
669        assert!(result.is_ok());
670
671        let actual = Arc::try_unwrap(actual).unwrap().into_inner();
672        let expect = 99 * 100 / 2;
673        assert_eq!(expect, actual);
674    }
675
676    #[tokio::test]
677    async fn for_each_max_concurrency() {
678        #[derive(Default, Debug)]
679        struct Jobs {
680            max: AtomicUsize,
681            curr: AtomicUsize,
682        }
683
684        let jobs = Arc::new(Jobs::default());
685
686        let result = stream::iter(0..32)
687            .try_for_each_spawned(4, |_| {
688                let jobs = jobs.clone();
689                async move {
690                    jobs.curr.fetch_add(1, Ordering::Relaxed);
691                    tokio::time::sleep(Duration::from_millis(100)).await;
692                    let prev = jobs.curr.fetch_sub(1, Ordering::Relaxed);
693                    jobs.max.fetch_max(prev, Ordering::Relaxed);
694                    Ok::<(), ()>(())
695                }
696            })
697            .await;
698
699        assert!(result.is_ok());
700
701        let Jobs { max, curr } = Arc::try_unwrap(jobs).unwrap();
702        assert_eq!(curr.into_inner(), 0);
703        assert!(max.into_inner() <= 4);
704    }
705
706    #[tokio::test]
707    async fn for_each_error_propagation() {
708        let actual = Arc::new(Mutex::new(vec![]));
709        let result = stream::iter(0..100)
710            .try_for_each_spawned(None, |i| {
711                let actual = actual.clone();
712                async move {
713                    if i < 42 {
714                        actual.lock().unwrap().push(i);
715                        Ok(())
716                    } else {
717                        Err(())
718                    }
719                }
720            })
721            .await;
722
723        assert!(result.is_err());
724
725        let actual = Arc::try_unwrap(actual).unwrap().into_inner().unwrap();
726        let expect: Vec<_> = (0..42).collect();
727        assert_eq!(expect, actual);
728    }
729
730    #[tokio::test]
731    #[should_panic]
732    async fn for_each_panic_propagation() {
733        let _ = stream::iter(0..100)
734            .try_for_each_spawned(None, |i| async move {
735                assert!(i < 42);
736                Ok::<(), ()>(())
737            })
738            .await;
739    }
740
741    #[tokio::test]
742    async fn send_spawned_basic() {
743        let (tx, mut rx) = mpsc::channel(100);
744        let result = stream::iter(0..10u64)
745            .try_for_each_send_spawned(
746                ConcurrencyConfig::fixed(4),
747                |i| async move { Ok::<_, Break<()>>(i * 2) },
748                tx,
749                |_| {},
750            )
751            .await;
752
753        assert!(result.is_ok());
754
755        let mut values = Vec::new();
756        while let Ok(v) = rx.try_recv() {
757            values.push(v);
758        }
759        values.sort();
760        let expected: Vec<u64> = (0..10).map(|i| i * 2).collect();
761        assert_eq!(values, expected);
762    }
763
764    #[tokio::test]
765    async fn send_spawned_error_propagation() {
766        let (tx, _rx) = mpsc::channel(100);
767        let result: Result<(), Break<String>> = stream::iter(0..10u64)
768            .try_for_each_send_spawned(
769                ConcurrencyConfig::fixed(1),
770                |i| async move {
771                    if i < 3 {
772                        Ok(i)
773                    } else {
774                        Err(Break::Err("fail".to_string()))
775                    }
776                },
777                tx,
778                |_| {},
779            )
780            .await;
781
782        assert!(matches!(result, Err(Break::Err(ref s)) if s == "fail"));
783    }
784
785    #[tokio::test]
786    async fn send_spawned_channel_closed() {
787        let (tx, rx) = mpsc::channel(1);
788        drop(rx);
789
790        let result: Result<(), Break<()>> = stream::iter(0..10u64)
791            .try_for_each_send_spawned(
792                ConcurrencyConfig::fixed(1),
793                |i| async move { Ok(i) },
794                tx,
795                |_| {},
796            )
797            .await;
798
799        assert!(matches!(result, Err(Break::Break)));
800    }
801
802    #[tokio::test]
803    async fn send_spawned_reports_stats() {
804        let reported: Arc<Mutex<Vec<ConcurrencyStats>>> = Arc::new(Mutex::new(Vec::new()));
805        let (tx, _rx) = mpsc::channel(100);
806
807        let reported2 = reported.clone();
808        let _ = stream::iter(0..5u64)
809            .try_for_each_send_spawned(
810                ConcurrencyConfig::fixed(2),
811                |i| async move { Ok::<_, Break<()>>(i) },
812                tx,
813                move |stats| {
814                    reported2.lock().unwrap().push(stats);
815                },
816            )
817            .await;
818
819        let reports = reported.lock().unwrap();
820        for stats in reports.iter() {
821            assert_eq!(stats.limit, 2);
822        }
823    }
824
825    #[tokio::test]
826    async fn broadcast_spawned_basic() {
827        let (tx1, mut rx1) = mpsc::channel(100);
828        let (tx2, mut rx2) = mpsc::channel(100);
829        let txs = vec![tx1, tx2];
830
831        let result = stream::iter(0..5u64)
832            .try_for_each_broadcast_spawned(
833                ConcurrencyConfig::fixed(2),
834                |i| async move { Ok::<_, Break<()>>(i * 3) },
835                txs,
836                |_| {},
837            )
838            .await;
839
840        assert!(result.is_ok());
841
842        let mut v1 = Vec::new();
843        while let Ok(v) = rx1.try_recv() {
844            v1.push(v);
845        }
846        let mut v2 = Vec::new();
847        while let Ok(v) = rx2.try_recv() {
848            v2.push(v);
849        }
850        v1.sort();
851        v2.sort();
852        let expected: Vec<u64> = (0..5).map(|i| i * 3).collect();
853        assert_eq!(v1, expected);
854        assert_eq!(v2, expected);
855    }
856
857    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
858    async fn send_spawned_adaptive_decreases_limit() {
859        // Use a small channel to force fill to rise quickly.
860        let (tx, mut rx) = mpsc::channel(4);
861        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
862
863        let limits2 = limits.clone();
864        let handle = tokio::spawn(async move {
865            stream::iter(0..100u64)
866                .try_for_each_send_spawned(
867                    ConcurrencyConfig::adaptive(10, 1, 20),
868                    |i| async move {
869                        // Simulate work so tasks take time
870                        tokio::time::sleep(Duration::from_millis(5)).await;
871                        Ok::<_, Break<()>>(i)
872                    },
873                    tx,
874                    move |stats| {
875                        limits2.lock().unwrap().push(stats.limit);
876                    },
877                )
878                .await
879        });
880
881        // Drain slowly so the channel fills up
882        let mut received = Vec::new();
883        loop {
884            tokio::time::sleep(Duration::from_millis(20)).await;
885            match rx.try_recv() {
886                Ok(v) => received.push(v),
887                Err(mpsc::error::TryRecvError::Empty) => {
888                    if handle.is_finished() {
889                        // Drain remaining
890                        while let Ok(v) = rx.try_recv() {
891                            received.push(v);
892                        }
893                        break;
894                    }
895                }
896                Err(mpsc::error::TryRecvError::Disconnected) => {
897                    while let Ok(v) = rx.try_recv() {
898                        received.push(v);
899                    }
900                    break;
901                }
902            }
903        }
904
905        handle.await.unwrap().unwrap();
906
907        let limits = limits.lock().unwrap();
908        let min_limit = limits.iter().copied().min().unwrap_or(10);
909        assert!(
910            min_limit < 10,
911            "Limit should have decreased from initial=10, min observed: {min_limit}"
912        );
913    }
914
915    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
916    async fn send_spawned_adaptive_recovers_after_decrease() {
917        let (tx, mut rx) = mpsc::channel(4);
918        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
919
920        let limits2 = limits.clone();
921        let handle = tokio::spawn(async move {
922            stream::iter(0..200u64)
923                .try_for_each_send_spawned(
924                    ConcurrencyConfig::adaptive(10, 1, 20),
925                    |i| async move {
926                        tokio::time::sleep(Duration::from_millis(5)).await;
927                        Ok::<_, Break<()>>(i)
928                    },
929                    tx,
930                    move |stats| {
931                        limits2.lock().unwrap().push(stats.limit);
932                    },
933                )
934                .await
935        });
936
937        // Phase 1: drain slowly so the channel fills and the limit decreases.
938        for _ in 0..60 {
939            tokio::time::sleep(Duration::from_millis(20)).await;
940            let _ = rx.try_recv();
941        }
942
943        // Record the lowest limit observed so far.
944        let low_water = {
945            let lims = limits.lock().unwrap();
946            lims.iter().copied().min().unwrap_or(10)
947        };
948        assert!(
949            low_water < 10,
950            "Limit should have decreased, min={low_water}"
951        );
952
953        // Phase 2: drain eagerly so fill drops and the limit recovers.
954        while (rx.recv().await).is_some() {
955            if handle.is_finished() {
956                while rx.try_recv().is_ok() {}
957                break;
958            }
959        }
960
961        handle.await.unwrap().unwrap();
962
963        let limits = limits.lock().unwrap();
964        let recovered_max = limits.iter().copied().rev().take(30).max().unwrap_or(0);
965        assert!(
966            recovered_max > low_water,
967            "Limit should have recovered above {low_water}, best late limit: {recovered_max}"
968        );
969    }
970
971    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
972    async fn send_spawned_adaptive_respects_min() {
973        let (tx, mut rx) = mpsc::channel(2);
974        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
975
976        let limits2 = limits.clone();
977        let handle = tokio::spawn(async move {
978            stream::iter(0..100u64)
979                .try_for_each_send_spawned(
980                    ConcurrencyConfig::adaptive(10, 5, 20),
981                    |i| async move {
982                        tokio::time::sleep(Duration::from_millis(10)).await;
983                        Ok::<_, Break<()>>(i)
984                    },
985                    tx,
986                    move |stats| {
987                        limits2.lock().unwrap().push(stats.limit);
988                    },
989                )
990                .await
991        });
992
993        // Drain very slowly to force congestion.
994        loop {
995            tokio::time::sleep(Duration::from_millis(50)).await;
996            match rx.try_recv() {
997                Ok(_) => {}
998                Err(mpsc::error::TryRecvError::Empty) => {
999                    if handle.is_finished() {
1000                        while rx.try_recv().is_ok() {}
1001                        break;
1002                    }
1003                }
1004                Err(mpsc::error::TryRecvError::Disconnected) => {
1005                    while rx.try_recv().is_ok() {}
1006                    break;
1007                }
1008            }
1009        }
1010
1011        handle.await.unwrap().unwrap();
1012
1013        let limits = limits.lock().unwrap();
1014        let min_limit = limits.iter().copied().min().unwrap_or(10);
1015        assert!(
1016            min_limit >= 5,
1017            "Limit should never drop below min=5, observed: {min_limit}"
1018        );
1019    }
1020
1021    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1022    async fn send_spawned_adaptive_respects_max() {
1023        let (tx, mut rx) = mpsc::channel(1000);
1024        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1025
1026        let limits2 = limits.clone();
1027        let handle = tokio::spawn(async move {
1028            stream::iter(0..200u64)
1029                .try_for_each_send_spawned(
1030                    ConcurrencyConfig::adaptive(2, 1, 8),
1031                    |i| async move {
1032                        tokio::time::sleep(Duration::from_millis(5)).await;
1033                        Ok::<_, Break<()>>(i)
1034                    },
1035                    tx,
1036                    move |stats| {
1037                        limits2.lock().unwrap().push(stats.limit);
1038                    },
1039                )
1040                .await
1041        });
1042
1043        // Drain eagerly so fill stays low and the limit keeps trying to increase.
1044        while rx.recv().await.is_some() {}
1045
1046        handle.await.unwrap().unwrap();
1047
1048        let limits = limits.lock().unwrap();
1049        let max_limit = limits.iter().copied().max().unwrap_or(0);
1050        assert!(
1051            max_limit <= 8,
1052            "Limit should never exceed max=8, observed: {max_limit}"
1053        );
1054    }
1055
1056    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1057    async fn send_spawned_epoch_prevents_stampede() {
1058        let (tx, mut rx) = mpsc::channel(2);
1059        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1060
1061        let limits2 = limits.clone();
1062        let handle = tokio::spawn(async move {
1063            stream::iter(0..60u64)
1064                .try_for_each_send_spawned(
1065                    ConcurrencyConfig::adaptive(20, 1, 20),
1066                    |i| async move {
1067                        tokio::time::sleep(Duration::from_millis(10)).await;
1068                        Ok::<_, Break<()>>(i)
1069                    },
1070                    tx,
1071                    move |stats| {
1072                        limits2.lock().unwrap().push(stats.limit);
1073                    },
1074                )
1075                .await
1076        });
1077
1078        // Don't drain initially so the channel fills up. Many tasks will complete while
1079        // the channel is full, exercising the epoch guard.
1080        tokio::time::sleep(Duration::from_millis(300)).await;
1081
1082        // Now drain to let the producer finish.
1083        while rx.recv().await.is_some() {}
1084        handle.await.unwrap().unwrap();
1085
1086        let limits = limits.lock().unwrap();
1087        // Deduplicate consecutive equal values to get actual transitions.
1088        let transitions: Vec<usize> = limits
1089            .iter()
1090            .copied()
1091            .collect::<Vec<_>>()
1092            .windows(2)
1093            .filter_map(|w| if w[0] != w[1] { Some(w[1]) } else { None })
1094            .collect();
1095
1096        // For every decrease, verify it's at most a single proportional cut.
1097        // The maximum single-step cut at fill=1.0 is: new = ceil(old * 0.5).
1098        for pair in limits.iter().copied().collect::<Vec<_>>().windows(2) {
1099            let (old, new) = (pair[0], pair[1]);
1100            if new < old {
1101                let min_allowed = ((old as f64) * 0.5).ceil() as usize;
1102                assert!(
1103                    new >= min_allowed,
1104                    "Stampede detected: limit dropped from {old} to {new}, \
1105                     minimum allowed single-step is {min_allowed}. Transitions: {transitions:?}"
1106                );
1107            }
1108        }
1109    }
1110
1111    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1112    async fn broadcast_spawned_slow_receiver_triggers_decrease() {
1113        let (tx_fast, mut rx_fast) = mpsc::channel(100);
1114        let (tx_slow, mut rx_slow) = mpsc::channel(4);
1115        let txs = vec![tx_fast, tx_slow];
1116        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1117
1118        let limits2 = limits.clone();
1119        let handle = tokio::spawn(async move {
1120            stream::iter(0..100u64)
1121                .try_for_each_broadcast_spawned(
1122                    ConcurrencyConfig::adaptive(10, 1, 20),
1123                    |i| async move {
1124                        tokio::time::sleep(Duration::from_millis(5)).await;
1125                        Ok::<_, Break<()>>(i)
1126                    },
1127                    txs,
1128                    move |stats| {
1129                        limits2.lock().unwrap().push(stats.limit);
1130                    },
1131                )
1132                .await
1133        });
1134
1135        // Drain the fast channel eagerly.
1136        let fast_drain = tokio::spawn(async move { while rx_fast.recv().await.is_some() {} });
1137
1138        // Drain the slow channel slowly.
1139        loop {
1140            tokio::time::sleep(Duration::from_millis(20)).await;
1141            match rx_slow.try_recv() {
1142                Ok(_) => {}
1143                Err(mpsc::error::TryRecvError::Empty) => {
1144                    if handle.is_finished() {
1145                        while rx_slow.try_recv().is_ok() {}
1146                        break;
1147                    }
1148                }
1149                Err(mpsc::error::TryRecvError::Disconnected) => {
1150                    while rx_slow.try_recv().is_ok() {}
1151                    break;
1152                }
1153            }
1154        }
1155
1156        handle.await.unwrap().unwrap();
1157        fast_drain.await.unwrap();
1158
1159        let limits = limits.lock().unwrap();
1160        let min_limit = limits.iter().copied().min().unwrap_or(10);
1161        assert!(
1162            min_limit < 10,
1163            "Limit should have decreased due to slow receiver, min observed: {min_limit}"
1164        );
1165    }
1166
1167    #[tokio::test]
1168    async fn broadcast_spawned_channel_closed() {
1169        let (tx1, _rx1) = mpsc::channel(100);
1170        let (tx2, rx2) = mpsc::channel(100);
1171        drop(rx2);
1172
1173        let result: Result<(), Break<()>> = stream::iter(0..10u64)
1174            .try_for_each_broadcast_spawned(
1175                ConcurrencyConfig::fixed(2),
1176                |i| async move { Ok(i) },
1177                vec![tx1, tx2],
1178                |_| {},
1179            )
1180            .await;
1181
1182        assert!(matches!(result, Err(Break::Break)));
1183    }
1184
1185    #[tokio::test]
1186    async fn fixed_concurrency_limit_never_changes() {
1187        let limits: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
1188        let (tx, mut rx) = mpsc::channel(2);
1189
1190        let limits2 = limits.clone();
1191        let handle = tokio::spawn(async move {
1192            stream::iter(0..20u64)
1193                .try_for_each_send_spawned(
1194                    ConcurrencyConfig::fixed(5),
1195                    |i| async move { Ok::<_, Break<()>>(i) },
1196                    tx,
1197                    move |stats| {
1198                        limits2.lock().unwrap().push(stats.limit);
1199                    },
1200                )
1201                .await
1202        });
1203
1204        // Drain the receiver so sends don't block.
1205        while rx.recv().await.is_some() {}
1206
1207        handle.await.unwrap().unwrap();
1208
1209        let limits = limits.lock().unwrap();
1210        for &g in limits.iter() {
1211            assert_eq!(g, 5, "Fixed limit should never change");
1212        }
1213    }
1214}