This is an automated email from the ASF dual-hosted git repository.
Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new cd7c6b83ab fix(parquet): split row groups iteratively instead of
recursively (#10626)
cd7c6b83ab is described below
commit cd7c6b83abd6605a83014b3d043930a592542510
Author: Stefan Wang <[email protected]>
AuthorDate: Sat Aug 22 06:44:07 2026 -0700
fix(parquet): split row groups iteratively instead of recursively (#10626)
# Which issue does this PR close?
- Closes #9386.
# Rationale for this change
`ArrowWriter::write` splits a batch by calling itself on each half, so
it recurses once per row group. A row limit much smaller than the batch
turns that into one stack frame per row group, and the process aborts:
```
thread '...' has overflowed its stack
fatal runtime error: stack overflow, aborting
```
The limits are caller-supplied, so a large batch written under a small
`max_row_group_row_count` takes down the process rather than returning
an error.
# What changes are included in this PR?
`write` now loops over the rows still to be written instead of
recursing. Each pass fills the in-progress row group, flushes when a
limit is reached, and carries the remainder into the next pass, so stack
use is constant no matter how many row groups a batch produces.
The split points and flush conditions are unchanged.
# Are these changes tested?
Yes. `test_row_group_limit_rows_only_many_splits` writes 50,000 rows
with `max_row_group_row_count = 1`, so the batch splits into 50,000 row
groups. On the current code it aborts the process:
```console
$ cargo test -p parquet --lib test_row_group_limit_rows_only_many_splits
running 1 test
thread
'arrow::arrow_writer::tests::test_row_group_limit_rows_only_many_splits'
(19274774) has overflowed its stack
fatal runtime error: stack overflow, aborting
error: test failed, to rerun pass `-p parquet --lib`
Caused by:
process didn't exit successfully: `.../parquet-80a96ba37d90b1a2
test_row_group_limit_rows_only_many_splits` (signal: 6, SIGABRT: process abort
signal)
```
`test_row_group_limit_both_apply_to_same_batch` pins the split points
where both limits bite the same batch: the row limit trims it to 5 rows
and the byte limit then trims those to 4, giving row groups of `[14,
6]`. It passes on the current code, so it holds the iterative version to
the same split points.
Both, plus the rest of the `test_row_group_limit_*` cases, on this
branch:
<details><summary>Raw output</summary>
```console
$ cargo test -p parquet --lib test_row_group_limit
test
arrow::arrow_writer::tests::test_row_group_limit_none_writes_single_row_group
... ok
test arrow::arrow_writer::tests::test_row_group_limit_rows_only ... ok
test
arrow::arrow_writer::tests::test_row_group_limit_both_apply_to_same_batch ... ok
test
arrow::arrow_writer::tests::test_row_group_limit_both_row_wins_multiple_batches
... ok
test
arrow::arrow_writer::tests::test_row_group_limit_both_row_wins_single_batch ...
ok
test arrow::arrow_writer::tests::test_row_group_limit_bytes_only ... ok
test
arrow::arrow_writer::tests::test_row_group_limit_bytes_flushes_when_current_group_already_too_large
... ok
test arrow::arrow_writer::tests::test_row_group_limit_both_bytes_wins ... ok
test arrow::arrow_writer::tests::test_row_group_limit_rows_only_many_splits
... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 1252 filtered
out; finished in 1.20s
$ cargo test -p parquet --lib
test result: ok. 1261 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 13.29s
```
</details>
The byte limit reaches its split path only once rows are already
buffered, and re-enters with an empty row group, so it stays shallow —
the row count limit was the one that could run away.
# Are there any user-facing changes?
No API change. Writes that previously aborted the process now complete.
---------
Signed-off-by: 1fanwang <[email protected]>
---
parquet/src/arrow/arrow_writer/mod.rs | 185 +++++++++++++++++++++++-----------
1 file changed, 128 insertions(+), 57 deletions(-)
diff --git a/parquet/src/arrow/arrow_writer/mod.rs
b/parquet/src/arrow/arrow_writer/mod.rs
index c49c919fa0..f8acb65044 100644
--- a/parquet/src/arrow/arrow_writer/mod.rs
+++ b/parquet/src/arrow/arrow_writer/mod.rs
@@ -362,74 +362,92 @@ impl<W: Write + Send> ArrowWriter<W> {
return Ok(());
}
- let in_progress = match &mut self.in_progress {
- Some(in_progress) => in_progress,
- x => x.insert(
- self.row_group_writer_factory
-
.create_row_group_writer(self.writer.flushed_row_groups().len())?,
- ),
- };
-
- if let Some(max_rows) = self.max_row_group_row_count
- && in_progress.buffered_rows + batch.num_rows() > max_rows
- {
- let to_write = max_rows - in_progress.buffered_rows;
- let a = batch.slice(0, to_write);
- let b = batch.slice(to_write, batch.num_rows() - to_write);
- self.write(&a)?;
- return self.write(&b);
- }
+ // Rows not yet handed to a row group writer. Splitting iterates here
instead of
+ // recursing, so a small row group limit over a large batch cannot
exhaust the stack.
+ let mut remaining = batch.clone();
+
+ loop {
+ let in_progress = match &mut self.in_progress {
+ Some(in_progress) => in_progress,
+ x => x.insert(
+ self.row_group_writer_factory
+
.create_row_group_writer(self.writer.flushed_row_groups().len())?,
+ ),
+ };
+ let buffered_rows = in_progress.buffered_rows;
- // Check byte limit: if we have buffered data, use measured average
row size
- // to split batch proactively before exceeding byte limit
- if let Some(max_bytes) = self.max_row_group_bytes
- && in_progress.buffered_rows > 0
- {
- let current_bytes = in_progress.get_estimated_total_bytes();
+ // Leading rows of `remaining` that still fit in the current row
group, when the
+ // rest has to go to a later one.
+ let mut split_at = match self.max_row_group_row_count {
+ Some(max_rows) if buffered_rows + remaining.num_rows() >
max_rows => {
+ Some(max_rows - buffered_rows)
+ }
+ _ => None,
+ };
- if current_bytes >= max_bytes {
- self.flush()?;
- return self.write(batch);
- }
+ // Check byte limit: if we have buffered data, use measured
average row size
+ // to split batch proactively before exceeding byte limit. Both
limits apply to
+ // the same rows, so measure against whatever the row limit
already trimmed
+ // `remaining` down to; otherwise the row limit would always win.
+ let candidate_rows = split_at.unwrap_or_else(||
remaining.num_rows());
- if let Some(avg_row_bytes) = current_bytes
- .checked_div(in_progress.buffered_rows)
- .filter(|avg_row_bytes| *avg_row_bytes > 0)
+ if let Some(max_bytes) = self.max_row_group_bytes
+ && buffered_rows > 0
{
- // At this point, `current_bytes < max_bytes` (checked above)
- let remaining_bytes = max_bytes - current_bytes;
- let rows_that_fit =
remaining_bytes.checked_div(avg_row_bytes).unwrap_or(0);
-
- if batch.num_rows() > rows_that_fit {
- if rows_that_fit > 0 {
- let a = batch.slice(0, rows_that_fit);
- let b = batch.slice(rows_that_fit, batch.num_rows() -
rows_that_fit);
- self.write(&a)?;
- return self.write(&b);
- } else {
- self.flush()?;
- return self.write(batch);
+ let current_bytes = in_progress.get_estimated_total_bytes();
+
+ if current_bytes >= max_bytes {
+ self.flush()?;
+ continue;
+ }
+
+ if let Some(avg_row_bytes) = current_bytes
+ .checked_div(buffered_rows)
+ .filter(|avg_row_bytes| *avg_row_bytes > 0)
+ {
+ // At this point, `current_bytes < max_bytes` (checked
above)
+ let remaining_bytes = max_bytes - current_bytes;
+ let rows_that_fit =
remaining_bytes.checked_div(avg_row_bytes).unwrap_or(0);
+
+ if candidate_rows > rows_that_fit {
+ if rows_that_fit > 0 {
+ split_at = Some(rows_that_fit);
+ } else {
+ self.flush()?;
+ continue;
+ }
}
}
}
- }
- match self.cdc_chunkers.as_mut() {
- Some(chunkers) => in_progress.write_with_chunkers(batch,
chunkers)?,
- None => in_progress.write(batch)?,
- }
+ let rest = split_at.map(|to_write| {
+ let rest = remaining.slice(to_write, remaining.num_rows() -
to_write);
+ remaining = remaining.slice(0, to_write);
+ rest
+ });
- let should_flush = self
- .max_row_group_row_count
- .is_some_and(|max| in_progress.buffered_rows >= max)
- || self
- .max_row_group_bytes
- .is_some_and(|max| in_progress.get_estimated_total_bytes() >=
max);
+ let in_progress = self.in_progress.as_mut().unwrap();
+ match self.cdc_chunkers.as_mut() {
+ Some(chunkers) => in_progress.write_with_chunkers(&remaining,
chunkers)?,
+ None => in_progress.write(&remaining)?,
+ }
+
+ let should_flush = self
+ .max_row_group_row_count
+ .is_some_and(|max| in_progress.buffered_rows >= max)
+ || self
+ .max_row_group_bytes
+ .is_some_and(|max| in_progress.get_estimated_total_bytes()
>= max);
+
+ if should_flush {
+ self.flush()?
+ }
- if should_flush {
- self.flush()?
+ match rest {
+ Some(rest) => remaining = rest,
+ None => return Ok(()),
+ }
}
- Ok(())
}
/// Writes the given buf bytes to the internal buffer.
@@ -5788,6 +5806,34 @@ mod tests {
);
}
+ #[test]
+ // A row limit far smaller than the batch splits it many times over; the
split must not
+ // consume stack proportional to the number of row groups.
+ fn test_row_group_limit_rows_only_many_splits() {
+ let props = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(1))
+ .set_max_row_group_bytes(None)
+ .build();
+
+ let rows = 50_000;
+ let builder = write_batches(
+ WriteBatchesShape {
+ num_batches: 1,
+ rows_per_batch: rows,
+ row_size: 4,
+ },
+ props,
+ );
+
+ let sizes = row_group_sizes(builder.metadata());
+ assert_eq!(sizes.len(), rows, "Every row should get its own row
group");
+ assert_eq!(
+ sizes.iter().sum::<i64>(),
+ rows as i64,
+ "Total rows should be preserved"
+ );
+ }
+
#[test]
// When only max_row_group_bytes is set, respect the byte limit
fn test_row_group_limit_bytes_only() {
@@ -5944,6 +5990,31 @@ mod tests {
assert_eq!(total_rows, 100, "Total rows should be preserved");
}
+ #[test]
+ // Both limits can apply to the same batch: the row limit trims it to 5
rows, and the
+ // byte limit then trims those 5 down to 4.
+ fn test_row_group_limit_both_apply_to_same_batch() {
+ let props = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(15))
+ .set_max_row_group_bytes(Some(1500))
+ .build();
+
+ let builder = write_batches(
+ WriteBatchesShape {
+ num_batches: 2,
+ rows_per_batch: 10,
+ row_size: 100,
+ },
+ props,
+ );
+
+ assert_eq!(
+ &row_group_sizes(builder.metadata()),
+ &[14, 6],
+ "Byte limit should still apply to a batch the row limit already
split"
+ );
+ }
+
#[test]
fn arrow_column_chunk_close_mut_drops_column_index() {
use crate::arrow::ArrowSchemaConverter;