Skip to main content

faucet_core/
batching.rs

1//! Re-chunking and bounded concurrency for connector writes and reads
2//! (RFC 0013, #880).
3//!
4//! Every sink re-chunks the page it is handed into backend-sized requests, and
5//! many run those requests concurrently under a limit. [`chunks`] honours the
6//! `batch_size` contract (`0` = no re-chunking, the whole page is one request)
7//! and [`for_each_concurrent`] / [`map_concurrent`] run work with at most `n`
8//! in flight, stopping at the first error.
9//!
10//! ```
11//! # async fn ex() -> Result<(), faucet_core::FaucetError> {
12//! use faucet_core::batching::{chunks, map_concurrent};
13//!
14//! let page: Vec<u32> = (0..10).collect();
15//! let sums = map_concurrent(chunks(&page, 4), 2, |chunk| async move {
16//!     Ok(chunk.iter().sum::<u32>())
17//! })
18//! .await?;
19//! assert_eq!(sums, vec![6, 22, 17]);
20//! # Ok(())
21//! # }
22//! ```
23
24use crate::error::FaucetError;
25use futures::{StreamExt, TryStreamExt};
26use std::future::Future;
27
28/// Split `items` into slices of at most `batch_size`. `batch_size == 0` means
29/// "don't re-chunk": the whole slice is one chunk. An empty slice yields no
30/// chunks.
31pub fn chunks<T>(items: &[T], batch_size: usize) -> impl ExactSizeIterator<Item = &[T]> {
32    let size = if batch_size == 0 {
33        items.len().max(1)
34    } else {
35        batch_size
36    };
37    items.chunks(size)
38}
39
40/// Split an owned vector the same way as [`chunks`], moving the items.
41pub fn into_chunks<T>(items: Vec<T>, batch_size: usize) -> Vec<Vec<T>> {
42    if items.is_empty() {
43        return Vec::new();
44    }
45    if batch_size == 0 || items.len() <= batch_size {
46        return vec![items];
47    }
48    let mut out = Vec::with_capacity(items.len().div_ceil(batch_size));
49    let mut iter = items.into_iter();
50    loop {
51        let chunk: Vec<T> = iter.by_ref().take(batch_size).collect();
52        if chunk.is_empty() {
53            return out;
54        }
55        out.push(chunk);
56    }
57}
58
59/// Run `f` on every item with at most `limit` in flight (`0` is treated as
60/// `1`). Stops at, and returns, the first error; work already in flight is
61/// dropped.
62pub async fn for_each_concurrent<I, F, Fut>(items: I, limit: usize, f: F) -> Result<(), FaucetError>
63where
64    I: IntoIterator,
65    F: FnMut(I::Item) -> Fut,
66    Fut: Future<Output = Result<(), FaucetError>>,
67{
68    futures::stream::iter(items)
69        .map(f)
70        .buffer_unordered(limit.max(1))
71        .try_for_each(|()| async { Ok(()) })
72        .await
73}
74
75/// Run `f` on every item with at most `limit` in flight (`0` is treated as
76/// `1`) and return the results **in input order**. Stops at, and returns, the
77/// first error.
78pub async fn map_concurrent<I, F, Fut, T>(
79    items: I,
80    limit: usize,
81    f: F,
82) -> Result<Vec<T>, FaucetError>
83where
84    I: IntoIterator,
85    F: FnMut(I::Item) -> Fut,
86    Fut: Future<Output = Result<T, FaucetError>>,
87{
88    futures::stream::iter(items)
89        .map(f)
90        .buffered(limit.max(1))
91        .try_collect()
92        .await
93}
94
95/// A concurrency limit shared across calls: a semaphore with `n` permits
96/// (`0` is treated as `1`). Cheap to clone; every clone shares the permits.
97/// Use it when the work to bound is spread over separate calls (requests
98/// spawned from several `write_batch` invocations); within one call prefer
99/// [`map_concurrent`].
100#[derive(Debug, Clone)]
101pub struct ConcurrencyLimit {
102    permits: std::sync::Arc<tokio::sync::Semaphore>,
103    limit: usize,
104}
105
106impl ConcurrencyLimit {
107    /// A limit of `n` concurrent runs.
108    pub fn new(n: usize) -> Self {
109        let limit = n.max(1);
110        Self {
111            permits: std::sync::Arc::new(tokio::sync::Semaphore::new(limit)),
112            limit,
113        }
114    }
115
116    /// The configured limit.
117    pub fn limit(&self) -> usize {
118        self.limit
119    }
120
121    /// Run `fut` once a permit is free.
122    pub async fn run<T>(&self, fut: impl Future<Output = T>) -> T {
123        let _permit = self
124            .permits
125            .acquire()
126            .await
127            .expect("the semaphore is never closed");
128        fut.await
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::sync::Arc;
136    use std::sync::atomic::{AtomicUsize, Ordering};
137    use std::time::Duration;
138
139    #[test]
140    fn chunks_honour_the_batch_size_contract() {
141        let v: Vec<u8> = (0..5).collect();
142        let sizes: Vec<usize> = chunks(&v, 2).map(<[u8]>::len).collect();
143        assert_eq!(sizes, vec![2, 2, 1]);
144        assert_eq!(chunks(&v, 0).len(), 1, "0 = one chunk");
145        assert_eq!(chunks(&v, 0).next().unwrap().len(), 5);
146        assert_eq!(chunks(&v, 9).len(), 1);
147        assert_eq!(chunks::<u8>(&[], 0).len(), 0);
148        assert_eq!(chunks::<u8>(&[], 3).len(), 0);
149    }
150
151    #[test]
152    fn into_chunks_moves_items() {
153        assert_eq!(
154            into_chunks(vec![1, 2, 3, 4, 5], 2),
155            vec![vec![1, 2], vec![3, 4], vec![5]]
156        );
157        assert_eq!(into_chunks(vec![1, 2, 3], 0), vec![vec![1, 2, 3]]);
158        assert_eq!(into_chunks(vec![1, 2], 2), vec![vec![1, 2]]);
159        assert!(into_chunks(Vec::<u8>::new(), 2).is_empty());
160        assert_eq!(into_chunks(vec![1, 2, 3, 4], 2).len(), 2);
161    }
162
163    #[tokio::test(start_paused = true)]
164    async fn concurrency_is_bounded_and_order_kept() {
165        let live = Arc::new(AtomicUsize::new(0));
166        let peak = Arc::new(AtomicUsize::new(0));
167        let out = map_concurrent(0..8u64, 3, |i| {
168            let (live, peak) = (live.clone(), peak.clone());
169            async move {
170                let now = live.fetch_add(1, Ordering::SeqCst) + 1;
171                peak.fetch_max(now, Ordering::SeqCst);
172                #[allow(clippy::disallowed_methods)]
173                tokio::time::sleep(Duration::from_millis(10 * (8 - i))).await;
174                live.fetch_sub(1, Ordering::SeqCst);
175                Ok(i * 2)
176            }
177        })
178        .await
179        .unwrap();
180        assert_eq!(out, (0..8).map(|i| i * 2).collect::<Vec<_>>());
181        assert_eq!(peak.load(Ordering::SeqCst), 3);
182        let done = Arc::new(AtomicUsize::new(0));
183        let d = done.clone();
184        for_each_concurrent(0..5, 0, move |_| {
185            let d = d.clone();
186            async move {
187                d.fetch_add(1, Ordering::SeqCst);
188                Ok(())
189            }
190        })
191        .await
192        .unwrap();
193        assert_eq!(done.load(Ordering::SeqCst), 5);
194    }
195
196    #[tokio::test(start_paused = true)]
197    async fn a_shared_limit_bounds_separate_calls() {
198        let limit = ConcurrencyLimit::new(0);
199        assert_eq!(limit.limit(), 1);
200        let limit = ConcurrencyLimit::new(2);
201        let live = Arc::new(AtomicUsize::new(0));
202        let peak = Arc::new(AtomicUsize::new(0));
203        let mut tasks = Vec::new();
204        for _ in 0..6 {
205            let (limit, live, peak) = (limit.clone(), live.clone(), peak.clone());
206            tasks.push(tokio::spawn(async move {
207                limit
208                    .run(async {
209                        let now = live.fetch_add(1, Ordering::SeqCst) + 1;
210                        peak.fetch_max(now, Ordering::SeqCst);
211                        #[allow(clippy::disallowed_methods)]
212                        tokio::time::sleep(Duration::from_millis(5)).await;
213                        live.fetch_sub(1, Ordering::SeqCst);
214                    })
215                    .await
216            }));
217        }
218        for t in tasks {
219            t.await.unwrap();
220        }
221        assert_eq!(peak.load(Ordering::SeqCst), 2);
222    }
223
224    #[tokio::test]
225    async fn the_first_error_stops_the_run() {
226        let r = for_each_concurrent(0..5, 2, |i| async move {
227            if i == 2 {
228                Err(FaucetError::Sink("boom".into()))
229            } else {
230                Ok(())
231            }
232        })
233        .await;
234        assert!(matches!(r, Err(FaucetError::Sink(m)) if m == "boom"));
235        let r: Result<Vec<u8>, _> =
236            map_concurrent(0..3u8, 1, |_| async { Err(FaucetError::Sink("x".into())) }).await;
237        assert!(r.is_err());
238    }
239}