Skip to main content

s2_sdk/
batching.rs

1//! Utilities for batching [AppendRecord]s.
2
3use std::{
4    pin::Pin,
5    task::{Context, Poll},
6    time::Duration,
7};
8
9use futures_core::Stream;
10use futures_util::StreamExt;
11use s2_common::{caps::RECORD_BATCH_MAX, read_extent::CountOrBytes};
12use tokio::time::Instant;
13
14use crate::types::{
15    AppendInput, AppendRecord, AppendRecordBatch, FencingToken, MeteredBytes, ValidationError,
16};
17
18const RECORD_BATCH_MIN: CountOrBytes = CountOrBytes { count: 1, bytes: 8 };
19
20#[derive(Debug, Clone)]
21/// Configuration for batching [`AppendRecord`]s.
22pub struct BatchingConfig {
23    linger: Duration,
24    max_batch_bytes: usize,
25    max_batch_records: usize,
26}
27
28impl Default for BatchingConfig {
29    fn default() -> Self {
30        Self {
31            linger: Duration::from_millis(5),
32            max_batch_bytes: RECORD_BATCH_MAX.bytes,
33            max_batch_records: RECORD_BATCH_MAX.count,
34        }
35    }
36}
37
38impl BatchingConfig {
39    /// Create a new [`BatchingConfig`] with default settings.
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Set the duration for how long to wait for more records before flushing a batch.
45    ///
46    /// Defaults to `5ms`.
47    pub fn with_linger(self, linger: Duration) -> Self {
48        Self { linger, ..self }
49    }
50
51    /// Set the maximum metered bytes per batch.
52    ///
53    /// **Note:** It must be at least `8B` and must not exceed `1MiB`.
54    ///
55    /// Defaults to `1MiB`.
56    pub fn with_max_batch_bytes(self, max_batch_bytes: usize) -> Result<Self, ValidationError> {
57        if max_batch_bytes < RECORD_BATCH_MIN.bytes {
58            return Err(ValidationError(format!(
59                "max_batch_bytes ({max_batch_bytes}) must be at least {}",
60                RECORD_BATCH_MIN.bytes
61            )));
62        }
63        if max_batch_bytes > RECORD_BATCH_MAX.bytes {
64            return Err(ValidationError(format!(
65                "max_batch_bytes ({max_batch_bytes}) must not exceed {}",
66                RECORD_BATCH_MAX.bytes
67            )));
68        }
69        Ok(Self {
70            max_batch_bytes,
71            ..self
72        })
73    }
74
75    /// Set the maximum number of records per batch.
76    ///
77    /// **Note:** It must be at least `1` and must not exceed `1000`.
78    ///
79    /// Defaults to `1000`.
80    pub fn with_max_batch_records(self, max_batch_records: usize) -> Result<Self, ValidationError> {
81        if max_batch_records < RECORD_BATCH_MIN.count {
82            return Err(ValidationError(format!(
83                "max_batch_records ({max_batch_records}) must be at least {}",
84                RECORD_BATCH_MIN.count
85            )));
86        }
87        if max_batch_records > RECORD_BATCH_MAX.count {
88            return Err(ValidationError(format!(
89                "max_batch_records ({max_batch_records}) must not exceed {}",
90                RECORD_BATCH_MAX.count
91            )));
92        }
93        Ok(Self {
94            max_batch_records,
95            ..self
96        })
97    }
98}
99
100/// A [`Stream`] that batches [`AppendRecord`]s into [`AppendInput`]s.
101pub struct AppendInputs {
102    pub(crate) batches: AppendRecordBatches,
103    pub(crate) fencing_token: Option<FencingToken>,
104    pub(crate) match_seq_num: Option<u64>,
105}
106
107impl AppendInputs {
108    /// Create a new [`AppendInputs`] with the given records and config.
109    pub fn new(
110        records: impl Stream<Item = impl Into<AppendRecord> + Send> + Send + Unpin + 'static,
111        config: BatchingConfig,
112    ) -> Self {
113        Self {
114            batches: AppendRecordBatches::new(records, config),
115            fencing_token: None,
116            match_seq_num: None,
117        }
118    }
119
120    /// Set the fencing token for all [`AppendInput`]s.
121    pub fn with_fencing_token(self, fencing_token: FencingToken) -> Self {
122        Self {
123            fencing_token: Some(fencing_token),
124            ..self
125        }
126    }
127
128    /// Set the match sequence number for the initial [`AppendInput`]. It will be auto-incremented
129    /// for the subsequent ones.
130    pub fn with_match_seq_num(self, seq_num: u64) -> Self {
131        Self {
132            match_seq_num: Some(seq_num),
133            ..self
134        }
135    }
136}
137
138impl Stream for AppendInputs {
139    type Item = Result<AppendInput, ValidationError>;
140
141    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
142        match self.batches.poll_next_unpin(cx) {
143            Poll::Ready(Some(Ok(batch))) => {
144                let match_seq_num = self.match_seq_num;
145                if let Some(seq_num) = self.match_seq_num.as_mut() {
146                    *seq_num += batch.len() as u64;
147                }
148                Poll::Ready(Some(Ok(AppendInput {
149                    records: batch,
150                    match_seq_num,
151                    fencing_token: self.fencing_token.clone(),
152                })))
153            }
154            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
155            Poll::Ready(None) => Poll::Ready(None),
156            Poll::Pending => Poll::Pending,
157        }
158    }
159}
160
161/// A [`Stream`] that batches [`AppendRecord`]s into [`AppendRecordBatch`]es.
162pub struct AppendRecordBatches {
163    inner: Pin<Box<dyn Stream<Item = Result<AppendRecordBatch, ValidationError>> + Send>>,
164}
165
166impl AppendRecordBatches {
167    /// Create a new [`AppendRecordBatches`] with the given records and config.
168    pub fn new(
169        records: impl Stream<Item = impl Into<AppendRecord> + Send> + Send + Unpin + 'static,
170        config: BatchingConfig,
171    ) -> Self {
172        Self {
173            inner: Box::pin(append_record_batches(records, config)),
174        }
175    }
176}
177
178impl Stream for AppendRecordBatches {
179    type Item = Result<AppendRecordBatch, ValidationError>;
180
181    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
182        self.inner.as_mut().poll_next(cx)
183    }
184}
185
186fn is_batch_full(config: &BatchingConfig, count: usize, bytes: usize) -> bool {
187    count >= config.max_batch_records || bytes >= config.max_batch_bytes
188}
189
190fn would_overflow_batch(
191    config: &BatchingConfig,
192    count: usize,
193    bytes: usize,
194    record: &AppendRecord,
195) -> bool {
196    count + 1 > config.max_batch_records || bytes + record.metered_bytes() > config.max_batch_bytes
197}
198
199fn append_record_batches(
200    mut records: impl Stream<Item = impl Into<AppendRecord> + Send> + Send + Unpin + 'static,
201    config: BatchingConfig,
202) -> impl Stream<Item = Result<AppendRecordBatch, ValidationError>> + Send + 'static {
203    async_stream::try_stream! {
204        let mut batch = AppendRecordBatch::with_capacity(config.max_batch_records);
205        let mut overflowed_record: Option<AppendRecord> = None;
206
207        let linger_deadline = tokio::time::sleep(config.linger);
208        tokio::pin!(linger_deadline);
209
210        'outer: loop {
211            let first_record = match overflowed_record.take() {
212                Some(record) => record,
213                None => match records.next().await {
214                    Some(item) => item.into(),
215                    None => break,
216                },
217            };
218
219            let record_bytes = first_record.metered_bytes();
220            if record_bytes > config.max_batch_bytes {
221                Err(ValidationError(format!(
222                    "record size in metered bytes ({record_bytes}) exceeds max_batch_bytes ({})",
223                    config.max_batch_bytes
224                )))?;
225            }
226            batch.push(first_record);
227
228            while !is_batch_full(&config, batch.len(), batch.metered_bytes())
229                && overflowed_record.is_none()
230            {
231                if batch.len() == 1 {
232                    linger_deadline
233                        .as_mut()
234                        .reset(Instant::now() + config.linger);
235                }
236
237                tokio::select! {
238                    next_record = records.next() => {
239                        match next_record {
240                            Some(record) => {
241                                let record: AppendRecord = record.into();
242                                if would_overflow_batch(&config, batch.len(), batch.metered_bytes(), &record) {
243                                    overflowed_record = Some(record);
244                                } else {
245                                    batch.push(record);
246                                }
247                            }
248                            None => {
249                                yield std::mem::replace(&mut batch, AppendRecordBatch::with_capacity(config.max_batch_records));
250                                break 'outer;
251                            }
252                        }
253                    },
254                    _ = &mut linger_deadline, if !batch.is_empty() => {
255                        break;
256                    }
257                };
258            }
259
260            yield std::mem::replace(
261                &mut batch,
262                AppendRecordBatch::with_capacity(config.max_batch_records),
263            );
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use assert_matches::assert_matches;
271    use futures_util::TryStreamExt;
272
273    use super::*;
274
275    #[tokio::test]
276    async fn batches_should_be_empty_when_record_stream_is_empty() {
277        let batches: Vec<_> = AppendRecordBatches::new(
278            futures_util::stream::iter::<Vec<AppendRecord>>(vec![]),
279            BatchingConfig::default(),
280        )
281        .collect()
282        .await;
283        assert_eq!(batches.len(), 0);
284    }
285
286    #[tokio::test]
287    async fn batches_respect_count_limit() -> Result<(), ValidationError> {
288        let records: Vec<_> = (0..10)
289            .map(|i| AppendRecord::new(format!("record{i}")))
290            .collect::<Result<_, _>>()?;
291        let config = BatchingConfig::default().with_max_batch_records(3)?;
292        let batches: Vec<_> = AppendRecordBatches::new(futures_util::stream::iter(records), config)
293            .try_collect()
294            .await?;
295
296        assert_eq!(batches.len(), 4);
297        assert_eq!(batches[0].len(), 3);
298        assert_eq!(batches[1].len(), 3);
299        assert_eq!(batches[2].len(), 3);
300        assert_eq!(batches[3].len(), 1);
301
302        Ok(())
303    }
304
305    #[tokio::test]
306    async fn batches_respect_bytes_limit() -> Result<(), ValidationError> {
307        let records: Vec<_> = (0..10)
308            .map(|i| AppendRecord::new(format!("record{i}")))
309            .collect::<Result<_, _>>()?;
310        let single_record_bytes = records[0].metered_bytes();
311        let max_batch_bytes = single_record_bytes * 3;
312
313        let config = BatchingConfig::default().with_max_batch_bytes(max_batch_bytes)?;
314        let batches: Vec<_> = AppendRecordBatches::new(futures_util::stream::iter(records), config)
315            .try_collect()
316            .await?;
317
318        assert_eq!(batches.len(), 4);
319        assert_eq!(batches[0].metered_bytes(), max_batch_bytes);
320        assert_eq!(batches[1].metered_bytes(), max_batch_bytes);
321        assert_eq!(batches[2].metered_bytes(), max_batch_bytes);
322        assert_eq!(batches[3].metered_bytes(), single_record_bytes);
323
324        Ok(())
325    }
326
327    #[tokio::test]
328    async fn batching_should_error_when_it_sees_oversized_record() -> Result<(), ValidationError> {
329        let record = AppendRecord::new("hello-world")?;
330        let record_bytes = record.metered_bytes();
331        let max_batch_bytes = 10;
332
333        let config = BatchingConfig::default().with_max_batch_bytes(max_batch_bytes)?;
334        let results: Vec<_> =
335            AppendRecordBatches::new(futures_util::stream::iter(vec![record]), config)
336                .collect()
337                .await;
338
339        assert_eq!(results.len(), 1);
340        assert_matches!(&results[0], Err(err) => {
341            assert_eq!(
342                err.to_string(),
343                format!("record size in metered bytes ({record_bytes}) exceeds max_batch_bytes ({max_batch_bytes})")
344            );
345        });
346
347        Ok(())
348    }
349}