Skip to main content

lance_datafusion/
chunker.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use std::pin::Pin;
5use std::task::Poll;
6use std::{collections::VecDeque, task::Context};
7
8use arrow::compute::kernels;
9use arrow_array::RecordBatch;
10use datafusion::physical_plan::{SendableRecordBatchStream, stream::RecordBatchStreamAdapter};
11use datafusion_common::DataFusionError;
12use futures::{Stream, StreamExt, TryStreamExt, ready};
13
14use lance_core::Result;
15use lance_core::error::DataFusionResult;
16
17/// Wraps a [`SendableRecordBatchStream`] into a stream of RecordBatch chunks of
18/// a given size.  This slices but does not copy any buffers.
19struct BatchReaderChunker {
20    /// The inner stream
21    inner: SendableRecordBatchStream,
22    /// The batches that have been read from the inner stream but not yet fully yielded
23    buffered: VecDeque<RecordBatch>,
24    /// The number of rows to yield in each chunk
25    output_size: usize,
26    /// The position within the first batch in the buffer to start yielding from
27    i: usize,
28}
29
30impl BatchReaderChunker {
31    fn new(inner: SendableRecordBatchStream, output_size: usize) -> Self {
32        Self {
33            inner,
34            buffered: VecDeque::new(),
35            output_size,
36            i: 0,
37        }
38    }
39
40    fn buffered_len(&self) -> usize {
41        let buffer_total: usize = self.buffered.iter().map(|batch| batch.num_rows()).sum();
42        buffer_total - self.i
43    }
44
45    async fn fill_buffer(&mut self, output_size: usize) -> Result<()> {
46        while self.buffered_len() < output_size {
47            match self.inner.next().await {
48                Some(Ok(batch)) => self.buffered.push_back(batch),
49                Some(Err(e)) => return Err(e.into()),
50                None => break,
51            }
52        }
53        Ok(())
54    }
55
56    async fn next(&mut self) -> Option<Result<Vec<RecordBatch>>> {
57        self.next_sized(self.output_size).await
58    }
59
60    async fn next_sized(&mut self, output_size: usize) -> Option<Result<Vec<RecordBatch>>> {
61        match self.fill_buffer(output_size).await {
62            Ok(_) => {}
63            Err(e) => return Some(Err(e)),
64        };
65
66        let mut batches = Vec::new();
67
68        let mut rows_collected = 0;
69
70        while rows_collected < output_size {
71            if let Some(batch) = self.buffered.pop_front() {
72                // Skip empty batch
73                if batch.num_rows() == 0 {
74                    continue;
75                }
76
77                let rows_remaining_in_batch = batch.num_rows() - self.i;
78                let rows_to_take =
79                    std::cmp::min(rows_remaining_in_batch, output_size - rows_collected);
80
81                if rows_to_take == rows_remaining_in_batch {
82                    // We're taking the whole batch, so we can just move it
83                    let batch = if self.i == 0 {
84                        batch
85                    } else {
86                        // We are taking the remainder of the batch, so we need to slice it
87                        batch.slice(self.i, rows_to_take)
88                    };
89                    batches.push(batch);
90                    self.i = 0;
91                } else {
92                    // We're taking a slice of the batch, so we need to copy it
93                    batches.push(batch.slice(self.i, rows_to_take));
94                    // And then we need to push the remainder back onto the front of the queue
95                    self.i += rows_to_take;
96                    self.buffered.push_front(batch);
97                }
98
99                rows_collected += rows_to_take;
100            } else {
101                break;
102            }
103        }
104
105        if batches.is_empty() {
106            None
107        } else {
108            Some(Ok(batches))
109        }
110    }
111
112    async fn next_at_most(&mut self, output_size: usize) -> Option<Result<Vec<RecordBatch>>> {
113        loop {
114            let batch = match self.buffered.pop_front() {
115                Some(batch) => batch,
116                None => match self.inner.next().await {
117                    Some(Ok(batch)) => batch,
118                    Some(Err(error)) => return Some(Err(error.into())),
119                    None => return None,
120                },
121            };
122
123            if batch.num_rows() == 0 {
124                continue;
125            }
126
127            let rows_remaining_in_batch = batch.num_rows() - self.i;
128            let rows_to_take = rows_remaining_in_batch.min(output_size);
129            if rows_to_take == rows_remaining_in_batch {
130                let batch = if self.i == 0 {
131                    batch
132                } else {
133                    batch.slice(self.i, rows_to_take)
134                };
135                self.i = 0;
136                return Some(Ok(vec![batch]));
137            }
138
139            let output = batch.slice(self.i, rows_to_take);
140            self.i += rows_to_take;
141            self.buffered.push_front(batch);
142            return Some(Ok(vec![output]));
143        }
144    }
145}
146
147struct VariableBatchReaderChunker<I> {
148    chunker: BatchReaderChunker,
149    output_sizes: I,
150    is_done: bool,
151}
152
153struct VariableBreakStreamState<I> {
154    chunker: BatchReaderChunker,
155    output_sizes: I,
156    rows_remaining: Option<usize>,
157    is_done: bool,
158}
159
160struct BreakStreamState {
161    max_rows: usize,
162    rows_seen: usize,
163    rows_remaining: usize,
164    batch: Option<RecordBatch>,
165}
166
167impl BreakStreamState {
168    fn next(mut self) -> Option<(Result<RecordBatch>, Self)> {
169        if self.rows_remaining == 0 {
170            return None;
171        }
172        if self.rows_remaining + self.rows_seen <= self.max_rows {
173            self.rows_seen = (self.rows_seen + self.rows_remaining) % self.max_rows;
174            self.rows_remaining = 0;
175            let next = self.batch.take().unwrap();
176            Some((Ok(next), self))
177        } else {
178            let rows_to_emit = self.max_rows - self.rows_seen;
179            self.rows_seen = 0;
180            self.rows_remaining -= rows_to_emit;
181            let batch = self.batch.as_mut().unwrap();
182            let next = batch.slice(0, rows_to_emit);
183            *batch = batch.slice(rows_to_emit, batch.num_rows() - rows_to_emit);
184            Some((Ok(next), self))
185        }
186    }
187}
188
189// Given a stream of record batches, and a desired break point, this will
190// make sure that a new record batch is emitted every time `break_point` rows
191// have passed.
192//
193// This method will not combine record batches in any way.  For example, if
194// the input lengths are [3, 5, 8, 3, 5], and the break point is 10 then the
195// output batches will be [3, 5, 2 (break inserted) 6, 3, 1 (break inserted) 4]
196pub fn break_stream(
197    stream: SendableRecordBatchStream,
198    max_chunk_size: usize,
199) -> Pin<Box<dyn Stream<Item = Result<RecordBatch>> + Send>> {
200    let mut rows_already_seen = 0;
201    stream
202        .map_ok(move |batch| {
203            let state = BreakStreamState {
204                rows_remaining: batch.num_rows(),
205                max_rows: max_chunk_size,
206                rows_seen: rows_already_seen,
207                batch: Some(batch),
208            };
209            rows_already_seen = (state.rows_seen + state.rows_remaining) % state.max_rows;
210
211            futures::stream::unfold(state, move |state| std::future::ready(state.next()))
212                .fuse()
213                .boxed()
214        })
215        .try_flatten()
216        .boxed()
217}
218
219/// Given a stream of record batches, this will yield batches of a fixed size.
220///
221/// In order to avoid copying data the batches will be converted into a stream of
222/// `Vec<RecordBatch>` where each item is a `Vec` of batches whose total size is
223/// `chunk_size`.
224pub fn chunk_stream(
225    stream: SendableRecordBatchStream,
226    chunk_size: usize,
227) -> Pin<Box<dyn Stream<Item = Result<Vec<RecordBatch>>> + Send>> {
228    let chunker = BatchReaderChunker::new(stream, chunk_size);
229    futures::stream::unfold(chunker, |mut chunker| async move {
230        match chunker.next().await {
231            Some(Ok(batches)) => Some((Ok(batches), chunker)),
232            Some(Err(e)) => Some((Err(e), chunker)),
233            None => None,
234        }
235    })
236    .fuse()
237    .boxed()
238}
239
240/// Preserve input batch boundaries while inserting the requested row boundaries.
241///
242/// The requested sizes must describe the complete input. Unlike
243/// [`chunk_stream_with_sizes`], this does not combine adjacent input batches. It
244/// only slices a batch when it crosses a requested boundary.
245///
246/// # Example
247///
248/// ```
249/// # use datafusion::physical_plan::SendableRecordBatchStream;
250/// # use lance_datafusion::chunker::break_stream_with_sizes;
251/// # fn split_stream(stream: SendableRecordBatchStream) {
252/// let batches = break_stream_with_sizes(stream, vec![512, 512, 256]);
253/// # drop(batches);
254/// # }
255/// ```
256pub fn break_stream_with_sizes<I>(
257    stream: SendableRecordBatchStream,
258    output_sizes: I,
259) -> Pin<Box<dyn Stream<Item = Result<Vec<RecordBatch>>> + Send>>
260where
261    I: IntoIterator<Item = usize>,
262    I::IntoIter: Send + 'static,
263{
264    let state = VariableBreakStreamState {
265        chunker: BatchReaderChunker::new(stream, 1),
266        output_sizes: output_sizes.into_iter(),
267        rows_remaining: None,
268        is_done: false,
269    };
270    futures::stream::unfold(state, |mut state| async move {
271        if state.is_done {
272            return None;
273        }
274
275        if state.rows_remaining.is_none() {
276            let Some(output_size) = state.output_sizes.next() else {
277                return match state.chunker.next_at_most(1).await {
278                    None => None,
279                    Some(Ok(_)) => {
280                        state.is_done = true;
281                        Some((
282                            Err(lance_core::Error::invalid_input(
283                                "Input contained more rows than the requested chunk sizes",
284                            )),
285                            state,
286                        ))
287                    }
288                    Some(Err(error)) => {
289                        state.is_done = true;
290                        Some((Err(error), state))
291                    }
292                };
293            };
294            if output_size == 0 {
295                state.is_done = true;
296                return Some((
297                    Err(lance_core::Error::invalid_input(
298                        "Requested chunk sizes must be greater than zero",
299                    )),
300                    state,
301                ));
302            }
303            state.rows_remaining = Some(output_size);
304        }
305
306        let Some(rows_remaining) = state.rows_remaining else {
307            state.is_done = true;
308            return Some((
309                Err(lance_core::Error::internal(
310                    "Requested chunk boundary was not initialized",
311                )),
312                state,
313            ));
314        };
315        match state.chunker.next_at_most(rows_remaining).await {
316            Some(Ok(batches)) => {
317                let actual_size = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
318                let Some(rows_remaining) = rows_remaining.checked_sub(actual_size) else {
319                    state.is_done = true;
320                    return Some((
321                        Err(lance_core::Error::internal(
322                            "A boundary-preserving chunk exceeded its requested row count",
323                        )),
324                        state,
325                    ));
326                };
327                state.rows_remaining = (rows_remaining > 0).then_some(rows_remaining);
328                Some((Ok(batches), state))
329            }
330            Some(Err(error)) => {
331                state.is_done = true;
332                Some((Err(error), state))
333            }
334            None => {
335                state.is_done = true;
336                Some((
337                    Err(lance_core::Error::invalid_input(format!(
338                        "Input ended with {rows_remaining} rows remaining in a requested chunk"
339                    ))),
340                    state,
341                ))
342            }
343        }
344    })
345    .boxed()
346}
347
348/// Given a stream of record batches, yield chunks with the requested row counts.
349///
350/// The requested sizes must describe the complete input. An error is returned if
351/// the input ends early, contains additional rows, or a requested size is zero.
352/// Sizes are consumed lazily as chunks are requested.
353///
354/// # Example
355///
356/// ```
357/// # use datafusion::physical_plan::SendableRecordBatchStream;
358/// # use lance_datafusion::chunker::chunk_stream_with_sizes;
359/// # fn split_stream(stream: SendableRecordBatchStream) {
360/// let chunks = chunk_stream_with_sizes(stream, vec![512, 512, 256]);
361/// # drop(chunks);
362/// # }
363/// ```
364pub fn chunk_stream_with_sizes<I>(
365    stream: SendableRecordBatchStream,
366    output_sizes: I,
367) -> Pin<Box<dyn Stream<Item = Result<Vec<RecordBatch>>> + Send>>
368where
369    I: IntoIterator<Item = usize>,
370    I::IntoIter: Send + 'static,
371{
372    let state = VariableBatchReaderChunker {
373        chunker: BatchReaderChunker::new(stream, 1),
374        output_sizes: output_sizes.into_iter(),
375        is_done: false,
376    };
377    futures::stream::unfold(state, |mut state| async move {
378        if state.is_done {
379            return None;
380        }
381
382        let Some(output_size) = state.output_sizes.next() else {
383            return match state.chunker.next_sized(1).await {
384                None => None,
385                Some(Ok(_)) => {
386                    state.is_done = true;
387                    Some((
388                        Err(lance_core::Error::invalid_input(
389                            "Input contained more rows than the requested chunk sizes",
390                        )),
391                        state,
392                    ))
393                }
394                Some(Err(error)) => {
395                    state.is_done = true;
396                    Some((Err(error), state))
397                }
398            };
399        };
400
401        if output_size == 0 {
402            state.is_done = true;
403            return Some((
404                Err(lance_core::Error::invalid_input(
405                    "Requested chunk sizes must be greater than zero",
406                )),
407                state,
408            ));
409        }
410
411        match state.chunker.next_sized(output_size).await {
412            Some(Ok(batches)) => {
413                let actual_size = batches.iter().map(RecordBatch::num_rows).sum::<usize>();
414                if actual_size == output_size {
415                    Some((Ok(batches), state))
416                } else {
417                    state.is_done = true;
418                    Some((
419                        Err(lance_core::Error::invalid_input(format!(
420                            "Input ended after {actual_size} rows while filling a requested {output_size}-row chunk"
421                        ))),
422                        state,
423                    ))
424                }
425            }
426            Some(Err(error)) => {
427                state.is_done = true;
428                Some((Err(error), state))
429            }
430            None => {
431                state.is_done = true;
432                Some((
433                    Err(lance_core::Error::invalid_input(format!(
434                        "Input ended before a requested {output_size}-row chunk could be filled"
435                    ))),
436                    state,
437                ))
438            }
439        }
440    })
441    .boxed()
442}
443
444/// Given a stream of record batches, this will yield batches of a fixed size.
445///
446/// This stream _will_ combine record batches and so it can be fairly expensive as it will
447/// likely force a copy of incoming data.  However, it can be useful when users require
448/// precise batch sizing.
449pub fn chunk_concat_stream(
450    stream: SendableRecordBatchStream,
451    chunk_size: usize,
452) -> SendableRecordBatchStream {
453    let schema = stream.schema();
454    let schema_copy = schema.clone();
455    let chunked = chunk_stream(stream, chunk_size);
456    let chunk_concat = chunked
457        .and_then(move |batches| {
458            std::future::ready(
459                // chunk_stream is zero-copy and so it gives us pieces of batches.  However, the btree
460                // index needs 1 batch-per-page and so we concatenate here.
461                kernels::concat::concat_batches(&schema, batches.iter()).map_err(|e| e.into()),
462            )
463        })
464        .map_err(DataFusionError::from)
465        .boxed();
466    Box::pin(RecordBatchStreamAdapter::new(schema_copy, chunk_concat))
467}
468
469/// Given a stream of record batches, this will yield batches of a fixed size.
470///
471/// This stream _will_ combine record batches and so it can be fairly expensive as it will
472/// likely force a copy of all incoming data.  However, it can be useful when users require
473/// precise batch sizing.
474pub struct StrictBatchSizeStream<S> {
475    inner: S,
476    batch_size: usize,
477    residual: Option<RecordBatch>,
478}
479
480impl<S: Stream<Item = DataFusionResult<RecordBatch>> + Unpin> StrictBatchSizeStream<S> {
481    pub fn new(inner: S, batch_size: usize) -> Self {
482        Self {
483            inner,
484            batch_size,
485            residual: None,
486        }
487    }
488}
489
490/// Internal polling method for strict batch size enforcement.
491///
492/// # Use Case
493/// When precise batch sizing is required (e.g., ML batch processing), this method guarantees
494/// output batches exactly match batch_size until final partial batch. Maintains data integrity
495/// across splits using row-aware splitting.
496///
497/// # Example
498/// With batch_size=5 and input sequence:
499/// - Fragment 1: 7 rows → splits into `[5,2]`
500///   (queues 5, carries 2)
501/// - Fragment 2: 4 rows → combines carried 2 + 4 = 6
502///   splits into `[5,1]`
503///
504/// - Output batches: `[5]`, `[5]`, `[1]`
505impl<S> Stream for StrictBatchSizeStream<S>
506where
507    S: Stream<Item = DataFusionResult<RecordBatch>> + Unpin,
508{
509    type Item = DataFusionResult<RecordBatch>;
510
511    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
512        loop {
513            // Process residual first if present
514            if let Some(residual) = self.residual.take() {
515                if residual.num_rows() >= self.batch_size {
516                    let split_at = self.batch_size;
517                    let chunk = residual.slice(0, split_at);
518                    let new_residual = residual.slice(split_at, residual.num_rows() - split_at);
519                    self.residual = Some(new_residual);
520                    return Poll::Ready(Some(Ok(chunk)));
521                } else {
522                    // Keep residual and proceed to get more data
523                    self.residual = Some(residual);
524                }
525            }
526
527            // Poll the inner stream for next batch
528            match ready!(Pin::new(&mut self.inner).poll_next(cx)) {
529                Some(Ok(batch)) => {
530                    // Combine with residual if any
531                    let current_batch = if let Some(residual) = self.residual.take() {
532                        arrow::compute::concat_batches(&residual.schema(), &[residual, batch])
533                            .map_err(|e| DataFusionError::External(Box::new(e)))?
534                    } else {
535                        batch
536                    };
537
538                    if current_batch.num_rows() >= self.batch_size {
539                        let split_at = self.batch_size;
540                        let chunk = current_batch.slice(0, split_at);
541                        let new_residual =
542                            current_batch.slice(split_at, current_batch.num_rows() - split_at);
543                        if new_residual.num_rows() > 0 {
544                            self.residual = Some(new_residual);
545                        }
546                        return Poll::Ready(Some(Ok(chunk)));
547                    } else {
548                        // Not enough rows, store as residual
549                        self.residual = Some(current_batch);
550                        continue;
551                    }
552                }
553                Some(Err(e)) => return Poll::Ready(Some(Err(e))),
554                None => {
555                    return Poll::Ready(
556                        self.residual
557                            .take()
558                            .filter(|r| r.num_rows() > 0)
559                            .map(Ok::<_, DataFusionError>),
560                    );
561                }
562            }
563        }
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use std::sync::{
570        Arc,
571        atomic::{AtomicUsize, Ordering},
572    };
573
574    use arrow::datatypes::{Int32Type, Int64Type};
575    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
576    use futures::{StreamExt, TryStreamExt};
577    use lance_datagen::{BatchCount, RowCount, array};
578
579    use crate::datagen::DatafusionDatagenExt;
580
581    #[tokio::test]
582    async fn test_chunkers() {
583        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
584            arrow::datatypes::Field::new("", arrow::datatypes::DataType::Int32, false),
585        ]));
586
587        let make_batch = |num_rows: u32| {
588            lance_datagen::gen_batch()
589                .anon_col(lance_datagen::array::step::<Int32Type>())
590                .into_batch_rows(RowCount::from(num_rows as u64))
591                .unwrap()
592        };
593
594        let batches = vec![make_batch(10), make_batch(5), make_batch(13), make_batch(0)];
595
596        let make_stream = || {
597            let stream = futures::stream::iter(
598                batches
599                    .clone()
600                    .into_iter()
601                    .map(datafusion_common::Result::Ok),
602            )
603            .boxed();
604            Box::pin(RecordBatchStreamAdapter::new(schema.clone(), stream))
605        };
606
607        let chunked = super::chunk_stream(make_stream(), 10)
608            .try_collect::<Vec<_>>()
609            .await
610            .unwrap();
611
612        assert_eq!(chunked.len(), 3);
613        assert_eq!(chunked[0].len(), 1);
614        assert_eq!(chunked[0][0].num_rows(), 10);
615        assert_eq!(chunked[1].len(), 2);
616        assert_eq!(chunked[1][0].num_rows(), 5);
617        assert_eq!(chunked[1][1].num_rows(), 5);
618        assert_eq!(chunked[2].len(), 1);
619        assert_eq!(chunked[2][0].num_rows(), 8);
620
621        let sizes_consumed = Arc::new(AtomicUsize::new(0));
622        let requested_sizes = [9, 10, 9].into_iter().inspect({
623            let sizes_consumed = sizes_consumed.clone();
624            move |_| {
625                sizes_consumed.fetch_add(1, Ordering::SeqCst);
626            }
627        });
628        let mut chunked = super::chunk_stream_with_sizes(make_stream(), requested_sizes);
629        assert_eq!(sizes_consumed.load(Ordering::SeqCst), 0);
630        let first_chunk = chunked.next().await.unwrap().unwrap();
631        assert_eq!(sizes_consumed.load(Ordering::SeqCst), 1);
632        let mut chunked = chunked.try_collect::<Vec<_>>().await.unwrap();
633        chunked.insert(0, first_chunk);
634        assert_eq!(sizes_consumed.load(Ordering::SeqCst), 3);
635        assert_eq!(
636            chunked
637                .iter()
638                .map(|batches| batches.iter().map(|batch| batch.num_rows()).sum::<usize>())
639                .collect::<Vec<_>>(),
640            vec![9, 10, 9]
641        );
642
643        let error = super::chunk_stream_with_sizes(make_stream(), vec![10, 17])
644            .try_collect::<Vec<_>>()
645            .await
646            .unwrap_err();
647        assert!(
648            error
649                .to_string()
650                .contains("more rows than the requested chunk sizes")
651        );
652
653        let error = super::chunk_stream_with_sizes(make_stream(), vec![10, 19])
654            .try_collect::<Vec<_>>()
655            .await
656            .unwrap_err();
657        assert!(error.to_string().contains("ended after 18 rows"));
658
659        let sizes_consumed = Arc::new(AtomicUsize::new(0));
660        let requested_sizes = [9, 10, 9].into_iter().inspect({
661            let sizes_consumed = sizes_consumed.clone();
662            move |_| {
663                sizes_consumed.fetch_add(1, Ordering::SeqCst);
664            }
665        });
666        let mut broken = super::break_stream_with_sizes(make_stream(), requested_sizes);
667        assert_eq!(sizes_consumed.load(Ordering::SeqCst), 0);
668        let first_batch = broken.next().await.unwrap().unwrap();
669        assert_eq!(sizes_consumed.load(Ordering::SeqCst), 1);
670        let mut broken = broken.try_collect::<Vec<_>>().await.unwrap();
671        broken.insert(0, first_batch);
672        assert_eq!(sizes_consumed.load(Ordering::SeqCst), 3);
673        assert_eq!(
674            broken
675                .iter()
676                .map(|batches| batches.iter().map(|batch| batch.num_rows()).sum::<usize>())
677                .collect::<Vec<_>>(),
678            vec![9, 1, 5, 4, 9]
679        );
680
681        let error = super::break_stream_with_sizes(make_stream(), vec![27])
682            .try_collect::<Vec<_>>()
683            .await
684            .unwrap_err();
685        assert!(
686            error
687                .to_string()
688                .contains("more rows than the requested chunk sizes")
689        );
690
691        let error = super::break_stream_with_sizes(make_stream(), vec![29])
692            .try_collect::<Vec<_>>()
693            .await
694            .unwrap_err();
695        assert!(error.to_string().contains("1 rows remaining"));
696
697        let chunked = super::chunk_concat_stream(make_stream(), 10)
698            .try_collect::<Vec<_>>()
699            .await
700            .unwrap();
701
702        assert_eq!(chunked.len(), 3);
703        assert_eq!(chunked[0].num_rows(), 10);
704        assert_eq!(chunked[1].num_rows(), 10);
705        assert_eq!(chunked[2].num_rows(), 8);
706
707        let chunked = super::break_stream(make_stream(), 10)
708            .try_collect::<Vec<_>>()
709            .await
710            .unwrap();
711
712        assert_eq!(chunked.len(), 4);
713        assert_eq!(chunked[0].num_rows(), 10);
714        assert_eq!(chunked[1].num_rows(), 5);
715        assert_eq!(chunked[2].num_rows(), 5);
716        assert_eq!(chunked[3].num_rows(), 8);
717    }
718
719    #[tokio::test]
720    async fn test_strict_batch_size_stream() {
721        let batches = lance_datagen::gen_batch()
722            .anon_col(array::step::<Int32Type>())
723            .anon_col(array::step::<Int64Type>())
724            .into_df_stream(RowCount::from(7), BatchCount::from(10));
725
726        let stream = super::StrictBatchSizeStream::new(batches, 10);
727
728        let batches = stream.try_collect::<Vec<_>>().await.unwrap();
729        assert_eq!(batches.len(), 7);
730
731        for batch in batches {
732            assert_eq!(batch.num_rows(), 10);
733        }
734    }
735}