Skip to main content

lance_arrow/
stream.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Utilities for working with streams of [`RecordBatch`].
5
6use arrow_array::RecordBatch;
7use arrow_schema::{ArrowError, SchemaRef};
8use futures::stream::{self, Stream, StreamExt};
9use std::pin::Pin;
10
11use crate::deepcopy::deep_copy_batch_sliced;
12
13/// Rechunks a stream of [`RecordBatch`] so that each output batch has
14/// approximately `target_bytes` of array data.
15///
16/// Small input batches are accumulated (by concatenation) until at least
17/// `min_bytes` of data has been collected. If the resulting batch exceeds
18/// `max_bytes`, it is sliced into roughly equal pieces of ~`max_bytes`
19/// (assuming uniform row sizes).
20pub fn rechunk_stream_by_size<S, E>(
21    input: S,
22    input_schema: SchemaRef,
23    min_bytes: usize,
24    max_bytes: usize,
25) -> impl Stream<Item = Result<RecordBatch, E>>
26where
27    S: Stream<Item = Result<RecordBatch, E>>,
28    E: From<ArrowError>,
29{
30    rechunk_stream_by_size_inner(input, input_schema, min_bytes, max_bytes, false)
31}
32
33/// Like [`rechunk_stream_by_size`] but deep-copies slices so that
34/// `get_array_memory_size` reflects the true size of each output batch.
35///
36/// After a normal `RecordBatch::slice`, the backing buffers are shared with
37/// the original batch, so `get_array_memory_size` still reports the full
38/// parent size.  This variant deep-copies every slice produced during the
39/// splitting phase, which allows the stream to detect and re-split slices
40/// that still exceed `max_bytes` (e.g. because a single row is much larger
41/// than average).
42///
43/// The deep copy is a last resort and potentially expensive for large
44/// batches.  However, it is only performed when a batch actually needs to be
45/// sliced — batches that are already within the target range pass through at
46/// zero cost.  Use this only when the hard cap on `max_bytes` is a
47/// correctness requirement, not merely a performance hint.
48pub fn rechunk_stream_by_size_deep_copy<S, E>(
49    input: S,
50    input_schema: SchemaRef,
51    min_bytes: usize,
52    max_bytes: usize,
53) -> impl Stream<Item = Result<RecordBatch, E>>
54where
55    S: Stream<Item = Result<RecordBatch, E>>,
56    E: From<ArrowError>,
57{
58    rechunk_stream_by_size_inner(input, input_schema, min_bytes, max_bytes, true)
59}
60
61fn rechunk_stream_by_size_inner<S, E>(
62    input: S,
63    input_schema: SchemaRef,
64    min_bytes: usize,
65    max_bytes: usize,
66    deep_copy: bool,
67) -> impl Stream<Item = Result<RecordBatch, E>>
68where
69    S: Stream<Item = Result<RecordBatch, E>>,
70    E: From<ArrowError>,
71{
72    stream::try_unfold(
73        RechunkState {
74            input: Box::pin(input),
75            accumulated: Vec::new(),
76            acc_bytes: 0,
77            done: false,
78            input_schema,
79            min_bytes,
80            max_bytes,
81            deep_copy,
82        },
83        |mut state| async move {
84            if state.done && state.accumulated.is_empty() {
85                return Ok(None);
86            }
87
88            // Pull batches until we reach the byte target or exhaust input.
89            // Always pull at least one batch so that min_bytes=0 works.
90            while !state.done && (state.accumulated.is_empty() || state.acc_bytes < state.min_bytes)
91            {
92                match state.input.next().await {
93                    Some(Ok(batch)) => {
94                        state.acc_bytes += batch.get_array_memory_size();
95                        state.accumulated.push(batch);
96                    }
97                    Some(Err(e)) => return Err(e),
98                    None => {
99                        state.done = true;
100                    }
101                }
102            }
103
104            if state.accumulated.is_empty() {
105                return Ok(None);
106            }
107
108            // Fast path: if the first accumulated batch already meets the
109            // byte threshold, deliver it directly instead of concatenating
110            // everything together (which would just get sliced back apart).
111            if state.accumulated.len() > 1
112                && state.accumulated[0].get_array_memory_size() >= state.min_bytes
113            {
114                let b = state.accumulated.remove(0);
115                state.acc_bytes -= b.get_array_memory_size();
116                return Ok(Some((b, state)));
117            }
118
119            let batch = if state.accumulated.len() == 1 {
120                state.accumulated.pop().unwrap()
121            } else {
122                let b =
123                    arrow_select::concat::concat_batches(&state.input_schema, &state.accumulated)
124                        .map_err(E::from)?;
125                state.accumulated.clear();
126                b
127            };
128            state.acc_bytes = 0;
129
130            // Slice the batch into ~max_bytes pieces assuming uniform row sizes.
131            let mut slices =
132                slice_batch(batch, state.max_bytes, state.deep_copy).map_err(E::from)?;
133
134            if slices.len() == 1 {
135                Ok(Some((slices.pop().unwrap(), state)))
136            } else {
137                let first = slices.remove(0);
138
139                // Stash leftover slices for subsequent iterations.
140                for a in &slices {
141                    state.acc_bytes += a.get_array_memory_size();
142                }
143                state.accumulated = slices;
144
145                Ok(Some((first, state)))
146            }
147        },
148    )
149}
150
151/// Slice a batch into pieces of at most `max_bytes`.
152///
153/// When `deep_copy` is false, slices share buffers with the original batch
154/// and `get_array_memory_size` will still report the parent buffer size.
155/// This is fine when the caller only needs approximate sizing.
156///
157/// When `deep_copy` is true, each slice is deep-copied so that
158/// `get_array_memory_size` reflects the true size.  If a deep-copied slice
159/// still exceeds `max_bytes` (due to non-uniform row sizes), it is
160/// recursively split until every piece is within budget or contains only a
161/// single row.
162fn slice_batch(
163    batch: RecordBatch,
164    max_bytes: usize,
165    deep_copy: bool,
166) -> Result<Vec<RecordBatch>, ArrowError> {
167    let batch_bytes = batch.get_array_memory_size();
168    let num_rows = batch.num_rows();
169
170    if batch_bytes <= max_bytes {
171        return Ok(vec![batch]);
172    }
173
174    if num_rows <= 1 {
175        // A single row cannot be split further, but the size just measured may
176        // belong to its source buffer rather than to the row:
177        // `get_array_memory_size` reports whole buffers, and a slice shares
178        // them. Returning it uncopied hands the caller that inflated figure --
179        // a 100 KB row inside a 200 MB buffer measures as 200 MB, and a caller
180        // budgeting on it concludes the row cannot fit anywhere.
181        if deep_copy {
182            return Ok(vec![deep_copy_batch_sliced(&batch)?]);
183        }
184        return Ok(vec![batch]);
185    }
186
187    let rows_per_chunk = (max_bytes as u64 * num_rows as u64 / batch_bytes as u64).max(1) as usize;
188
189    let mut result = Vec::new();
190    let mut offset = 0;
191    while offset < num_rows {
192        let len = rows_per_chunk.min(num_rows - offset);
193        let slice = batch.slice(offset, len);
194        if deep_copy {
195            let copied = deep_copy_batch_sliced(&slice)?;
196            // Recurse: the deep-copied slice has accurate sizes, so if it
197            // still exceeds max_bytes we can split further.
198            result.extend(slice_batch(copied, max_bytes, true)?);
199        } else {
200            result.push(slice);
201        }
202        offset += len;
203    }
204
205    Ok(result)
206}
207
208/// Internal state for [`rechunk_stream`].
209///
210/// Kept as a named struct so the `try_unfold` closure stays readable.
211struct RechunkState<S> {
212    input: Pin<Box<S>>,
213    accumulated: Vec<RecordBatch>,
214    acc_bytes: usize,
215    done: bool,
216    input_schema: SchemaRef,
217    min_bytes: usize,
218    max_bytes: usize,
219    deep_copy: bool,
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    use std::sync::Arc;
227
228    use arrow_array::Int32Array;
229    use arrow_schema::{DataType, Field, Schema};
230    use futures::executor::block_on;
231
232    /// A single row that shares a large source buffer must report its own
233    /// size, not the buffer's. Callers budget on this figure, and an inflated
234    /// one makes a small row look impossible to place.
235    #[test]
236    fn a_single_row_slice_reports_its_own_size() {
237        use arrow_array::LargeStringArray;
238
239        let row = "x".repeat(100 * 1024);
240        let values: Vec<&str> = (0..2000).map(|_| row.as_str()).collect();
241        let schema = Arc::new(Schema::new(vec![Field::new(
242            "text",
243            DataType::LargeUtf8,
244            false,
245        )]));
246        let batch =
247            RecordBatch::try_new(schema, vec![Arc::new(LargeStringArray::from(values))]).unwrap();
248        let whole = batch.get_array_memory_size();
249
250        let one_row = batch.slice(7, 1);
251        assert_eq!(
252            one_row.get_array_memory_size(),
253            whole,
254            "a slice shares its source buffer, which is the reason this test exists"
255        );
256
257        // Budget far below the row so the split recurses to the single-row floor.
258        let chunks = slice_batch(one_row, 1024, true).unwrap();
259        assert_eq!(chunks.len(), 1);
260        let measured = chunks[0].get_array_memory_size();
261        assert_eq!(chunks[0].num_rows(), 1);
262        assert!(
263            measured < whole / 100,
264            "single row still measured as {measured} bytes against a {whole} byte source"
265        );
266    }
267
268    fn make_batch(num_rows: usize) -> RecordBatch {
269        let schema = test_schema();
270        let values: Vec<i32> = (0..num_rows as i32).collect();
271        RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(values))]).unwrap()
272    }
273
274    fn test_schema() -> SchemaRef {
275        Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]))
276    }
277
278    fn collect_rechunked(
279        batches: Vec<RecordBatch>,
280        min_bytes: usize,
281        max_bytes: usize,
282    ) -> Vec<RecordBatch> {
283        let input = stream::iter(batches.into_iter().map(Ok::<_, ArrowError>));
284        let rechunked = rechunk_stream_by_size(input, test_schema(), min_bytes, max_bytes);
285        block_on(rechunked.collect::<Vec<_>>())
286            .into_iter()
287            .map(|r| r.unwrap())
288            .collect()
289    }
290
291    fn total_rows(batches: &[RecordBatch]) -> usize {
292        batches.iter().map(|b| b.num_rows()).sum()
293    }
294
295    #[test]
296    fn test_empty_stream() {
297        let result = collect_rechunked(vec![], 100, 200);
298        assert!(result.is_empty());
299    }
300
301    #[test]
302    fn test_single_batch_passthrough() {
303        let batch = make_batch(100);
304        let bytes = batch.get_array_memory_size();
305        // Batch is between min and max — should pass through as-is.
306        let result = collect_rechunked(vec![batch], bytes / 2, bytes * 2);
307        assert_eq!(result.len(), 1);
308        assert_eq!(result[0].num_rows(), 100);
309    }
310
311    #[test]
312    fn test_small_batches_concatenated() {
313        let one_batch_bytes = make_batch(10).get_array_memory_size();
314        let batches: Vec<_> = (0..8).map(|_| make_batch(10)).collect();
315        // min = 5 batches worth, max = 10 batches worth.
316        let result = collect_rechunked(batches, one_batch_bytes * 5, one_batch_bytes * 10);
317        assert_eq!(total_rows(&result), 80);
318        // Should have been concatenated into fewer batches than the 8 inputs.
319        assert!(
320            result.len() < 8,
321            "expected fewer output batches, got {}",
322            result.len()
323        );
324    }
325
326    #[test]
327    fn test_large_batch_sliced() {
328        let batch = make_batch(1000);
329        let bytes = batch.get_array_memory_size();
330        let result = collect_rechunked(vec![batch], bytes / 8, bytes / 4);
331        assert_eq!(total_rows(&result), 1000);
332        assert!(
333            result.len() >= 4,
334            "expected at least 4 slices, got {}",
335            result.len()
336        );
337    }
338
339    #[test]
340    fn test_sliced_leftovers_are_not_recombined() {
341        // Key test for the fast-path optimisation. When a large batch is
342        // sliced, leftover slices should be delivered one-at-a-time without
343        // being concatenated back together.  We verify this by checking that
344        // every output buffer pointer falls inside the original batch's
345        // allocation (i.e. they are all zero-copy slices, not fresh copies).
346        let batch = make_batch(1000);
347        let bytes = batch.get_array_memory_size();
348        let orig_data = batch.column(0).to_data();
349        let orig_buf = &orig_data.buffers()[0];
350        let orig_start = orig_buf.as_ptr() as usize;
351        let orig_end = orig_start + orig_buf.len();
352
353        let result = collect_rechunked(vec![batch], bytes / 8, bytes / 4);
354
355        assert_eq!(total_rows(&result), 1000);
356        assert!(result.len() >= 4);
357
358        for (i, b) in result.iter().enumerate() {
359            let ptr = b.column(0).to_data().buffers()[0].as_ptr() as usize;
360            assert!(
361                ptr >= orig_start && ptr < orig_end,
362                "slice {i} buffer at {ptr:#x} is outside the original allocation \
363                 [{orig_start:#x}, {orig_end:#x}) — it was re-concatenated"
364            );
365        }
366    }
367
368    #[test]
369    fn test_flush_remainder_on_stream_end() {
370        // Data below min_bytes should still be flushed when the stream ends.
371        let batch = make_batch(10);
372        let bytes = batch.get_array_memory_size();
373        let result = collect_rechunked(vec![batch], bytes * 100, bytes * 200);
374        assert_eq!(result.len(), 1);
375        assert_eq!(result[0].num_rows(), 10);
376    }
377
378    #[test]
379    fn test_large_then_small_batches() {
380        // After a large batch is fully drained, subsequent small batches
381        // should be accumulated normally.
382        let large = make_batch(1000);
383        let small_bytes = make_batch(10).get_array_memory_size();
384        let batches = vec![
385            large,
386            make_batch(10),
387            make_batch(10),
388            make_batch(10),
389            make_batch(10),
390            make_batch(10),
391        ];
392        let result = collect_rechunked(batches, small_bytes * 3, small_bytes * 100);
393        assert_eq!(total_rows(&result), 1050);
394        // The large batch should appear (possibly sliced) followed by
395        // concatenated small batches, so we should have fewer output batches
396        // than the 6 inputs.
397        assert!(result.len() < 6);
398    }
399
400    #[test]
401    fn test_row_preservation_across_slicing() {
402        // Verify that every input row appears exactly once in the output
403        // and in the correct order after slicing.
404        let batch = make_batch(237); // odd count to exercise remainder slice
405        let bytes = batch.get_array_memory_size();
406        let result = collect_rechunked(vec![batch], bytes / 8, bytes / 5);
407
408        assert_eq!(total_rows(&result), 237);
409
410        let values: Vec<i32> = result
411            .iter()
412            .flat_map(|b| {
413                b.column(0)
414                    .as_any()
415                    .downcast_ref::<Int32Array>()
416                    .unwrap()
417                    .values()
418                    .iter()
419                    .copied()
420            })
421            .collect();
422        let expected: Vec<i32> = (0..237).collect();
423        assert_eq!(values, expected);
424    }
425
426    #[test]
427    fn test_min_bytes_zero_still_yields_all_rows() {
428        // When min_bytes=0, the stream should still yield every batch.
429        // This is the "chop only, don't coalesce" use case.
430        let batches: Vec<_> = (0..5).map(|_| make_batch(100)).collect();
431        let batch_bytes = batches[0].get_array_memory_size();
432        let result = collect_rechunked(batches, 0, batch_bytes * 2);
433        assert_eq!(total_rows(&result), 500);
434    }
435
436    #[test]
437    fn test_min_bytes_zero_slices_oversized() {
438        // min_bytes=0 with a small max_bytes should still slice large batches.
439        let batch = make_batch(1000);
440        let bytes = batch.get_array_memory_size();
441        let result = collect_rechunked(vec![batch], 0, bytes / 4);
442        assert_eq!(total_rows(&result), 1000);
443        assert!(
444            result.len() >= 4,
445            "expected at least 4 slices, got {}",
446            result.len()
447        );
448    }
449
450    /// Build a batch with one variable-length string column.
451    /// Every row is `small_size` bytes except the row at index `big_row_idx`
452    /// which is `big_size` bytes.
453    fn make_variable_batch(
454        num_rows: usize,
455        small_size: usize,
456        big_row_idx: usize,
457        big_size: usize,
458    ) -> RecordBatch {
459        let schema = Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, false)]));
460        let values: Vec<String> = (0..num_rows)
461            .map(|i| {
462                if i == big_row_idx {
463                    "X".repeat(big_size)
464                } else {
465                    "x".repeat(small_size)
466                }
467            })
468            .collect();
469        let array = arrow_array::StringArray::from(values);
470        RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap()
471    }
472
473    fn variable_schema() -> SchemaRef {
474        Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, false)]))
475    }
476
477    fn collect_rechunked_variable(
478        batches: Vec<RecordBatch>,
479        min_bytes: usize,
480        max_bytes: usize,
481    ) -> Vec<RecordBatch> {
482        let input = stream::iter(batches.into_iter().map(Ok::<_, ArrowError>));
483        let rechunked =
484            rechunk_stream_by_size_deep_copy(input, variable_schema(), min_bytes, max_bytes);
485        block_on(rechunked.collect::<Vec<_>>())
486            .into_iter()
487            .map(|r| r.unwrap())
488            .collect()
489    }
490
491    #[test]
492    fn test_oversized_row_at_end() {
493        // 100 rows: 99 small (64 bytes each) + 1 large (100KiB) at the end.
494        let batch = make_variable_batch(100, 64, 99, 100 * 1024);
495        let max_bytes = 64 * 1024;
496        let result = collect_rechunked_variable(vec![batch], 0, max_bytes);
497        assert_eq!(total_rows(&result), 100);
498        for (i, b) in result.iter().enumerate() {
499            let size = b.get_array_memory_size();
500            assert!(
501                size <= max_bytes || b.num_rows() == 1,
502                "batch {i} has {size} bytes (max {max_bytes}) and {} rows",
503                b.num_rows()
504            );
505        }
506    }
507
508    #[test]
509    fn test_oversized_row_at_start() {
510        // 100 rows: 1 large (100KiB) at the start + 99 small (64 bytes each).
511        let batch = make_variable_batch(100, 64, 0, 100 * 1024);
512        let max_bytes = 64 * 1024;
513        let result = collect_rechunked_variable(vec![batch], 0, max_bytes);
514        assert_eq!(total_rows(&result), 100);
515        for (i, b) in result.iter().enumerate() {
516            let size = b.get_array_memory_size();
517            assert!(
518                size <= max_bytes || b.num_rows() == 1,
519                "batch {i} has {size} bytes (max {max_bytes}) and {} rows",
520                b.num_rows()
521            );
522        }
523    }
524
525    #[test]
526    fn test_oversized_row_in_middle() {
527        // 100 rows: 1 large (100KiB) in the middle + 99 small (64 bytes each).
528        let batch = make_variable_batch(100, 64, 50, 100 * 1024);
529        let max_bytes = 64 * 1024;
530        let result = collect_rechunked_variable(vec![batch], 0, max_bytes);
531        assert_eq!(total_rows(&result), 100);
532        for (i, b) in result.iter().enumerate() {
533            let size = b.get_array_memory_size();
534            assert!(
535                size <= max_bytes || b.num_rows() == 1,
536                "batch {i} has {size} bytes (max {max_bytes}) and {} rows",
537                b.num_rows()
538            );
539        }
540    }
541
542    #[test]
543    fn test_error_propagation() {
544        let input = stream::iter(vec![
545            Ok(make_batch(10)),
546            Err(ArrowError::ComputeError("boom".into())),
547            Ok(make_batch(10)),
548        ]);
549        let rechunked = rechunk_stream_by_size(input, test_schema(), 1, usize::MAX);
550        let results: Vec<Result<RecordBatch, ArrowError>> = block_on(rechunked.collect());
551        assert!(results.iter().any(|r| r.is_err()));
552    }
553}