adriangb opened a new issue, #10931:
URL: https://github.com/apache/arrow-rs/issues/10931

   ### Describe the bug
   
   `WriterPropertiesBuilder::set_write_batch_size` documents `write_batch_size` 
as the granularity at which the writer's other limits are enforced 
(`parquet/src/file/properties.rs:735-743`, `main` @ `0aece99d08`):
   
   > Sets write batch size (defaults to 1024 via [`DEFAULT_WRITE_BATCH_SIZE`]).
   >
   > For performance reasons, data for each column is written in batches of 
this size.
   >
   > Additional limits such as such as [`set_data_page_row_count_limit`] are 
checked between batches, and thus the write batch size value acts as an 
upper-bound on the enforcement granularity of other limits.
   
   `set_data_page_row_count_limit` (`properties.rs:724-725`) and 
`set_data_page_size_limit` (`properties.rs:1113-1114`) both restate this as 
"Note: this is a best effort limit based on value of `set_write_batch_size`".
   
   `GenericColumnWriter::write_batch_internal` does not read `write_batch_size` 
when the column's level data is compact 
(`parquet/src/column/writer/mod.rs:629-641`):
   
   ```rust
   let both_levels_compact = !matches!(def_levels, 
LevelDataRef::Materialized(_))
       && !matches!(rep_levels, LevelDataRef::Materialized(_));
   let has_levels = !matches!(def_levels, LevelDataRef::Absent)
       || !matches!(rep_levels, LevelDataRef::Absent);
   
   // When both level vectors are compact (Uniform or Absent), there is no
   // materialized slice to split and the per-mini-batch work is O(1), so we
   // can safely use a much larger batch size.
   let base_batch_size = if both_levels_compact && has_levels {
       self.props.data_page_row_count_limit()
   } else {
       self.props.write_batch_size()
   };
   ```
   
   `LevelDataRef::Uniform` (`mod.rs:382-386`) is what the Arrow writer produces 
for a flat, non-repeated column whose definition levels are all the same value, 
which is the ordinary case of a nullable column whose batch happens to contain 
no nulls. Such a column takes the first branch, so its mini-batch size is 
`data_page_row_count_limit` (default 20,000) and `write_batch_size` is never 
consulted. `set_write_batch_size` then has no observable effect on that column.
   
   Because the condition is on the level *data* and not on the schema, the same 
column with the same writer properties switches branches depending on whether a 
given batch happens to contain a null.
   
   ### To Reproduce
   
   ```
   cargo new repro && cd repro
   cargo add [email protected] [email protected] bytes@1
   ```
   
   ```rust
   // src/main.rs
   use arrow::array::{ArrayRef, Int64Array};
   use arrow::datatypes::{DataType, Field, Schema};
   use arrow::record_batch::RecordBatch;
   use parquet::arrow::ArrowWriter;
   use parquet::file::metadata::{PageIndexPolicy, ParquetMetaDataReader};
   use parquet::file::properties::WriterProperties;
   use std::sync::Arc;
   
   const ROWS: i64 = 12_000;
   
   fn rows_per_page(nullable: bool, with_null: bool, write_batch_size: usize) 
-> Vec<i64> {
       let schema = Arc::new(Schema::new(vec![Field::new("col", 
DataType::Int64, nullable)]));
       let col: Int64Array = (0..ROWS)
           .map(|i| if with_null && i == 0 { None } else { Some(i) })
           .collect();
       let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(col) as 
ArrayRef]).unwrap();
   
       let props = WriterProperties::builder()
           .set_write_batch_size(write_batch_size)
           .set_data_page_row_count_limit(3_000)
           .set_dictionary_enabled(false)
           .build();
   
       let mut buf = Vec::new();
       let mut writer = ArrowWriter::try_new(&mut buf, schema, 
Some(props)).unwrap();
       writer.write(&batch).unwrap();
       writer.close().unwrap();
   
       let md = ParquetMetaDataReader::new()
           .with_page_index_policy(PageIndexPolicy::Required)
           .parse_and_finish(&bytes::Bytes::from(buf))
           .unwrap();
       let total = md.row_group(0).num_rows();
       let pages = &md.offset_index().unwrap()[0][0].page_locations;
       (0..pages.len())
           .map(|i| pages.get(i + 1).map_or(total, |n| n.first_row_index) - 
pages[i].first_row_index)
           .collect()
   }
   
   fn main() {
       println!("{ROWS} Int64 rows in one batch, data_page_row_count_limit = 
3000, no dictionary\n");
       for (label, nullable, with_null) in [
           ("nullable=false               (def levels Absent)      ", false, 
false),
           ("nullable=true,  no null in batch (def levels Uniform)     ", true, 
false),
           ("nullable=true,  one null in batch (def levels Materialized)", 
true, true),
       ] {
           for wbs in [8usize, 64, 1024, 100_000] {
               println!(
                   "{label} write_batch_size={wbs:<6} -> {:?}",
                   rows_per_page(nullable, with_null, wbs)
               );
           }
           println!();
       }
   }
   ```
   
   ```
   cargo run --release
   ```
   
   Actual output on `parquet` 59.2.0:
   
   ```
   12000 Int64 rows in one batch, data_page_row_count_limit = 3000, no 
dictionary
   
   nullable=false               (def levels Absent)       write_batch_size=8    
  -> [3000, 3000, 3000, 3000]
   nullable=false               (def levels Absent)       write_batch_size=64   
  -> [3008, 3008, 3008, 2976]
   nullable=false               (def levels Absent)       write_batch_size=1024 
  -> [3072, 3072, 3072, 2784]
   nullable=false               (def levels Absent)       
write_batch_size=100000 -> [12000]
   
   nullable=true,  no null in batch (def levels Uniform)      
write_batch_size=8      -> [3000, 3000, 3000, 3000]
   nullable=true,  no null in batch (def levels Uniform)      
write_batch_size=64     -> [3000, 3000, 3000, 3000]
   nullable=true,  no null in batch (def levels Uniform)      
write_batch_size=1024   -> [3000, 3000, 3000, 3000]
   nullable=true,  no null in batch (def levels Uniform)      
write_batch_size=100000 -> [3000, 3000, 3000, 3000]
   
   nullable=true,  one null in batch (def levels Materialized) 
write_batch_size=8      -> [3000, 3000, 3000, 3000]
   nullable=true,  one null in batch (def levels Materialized) 
write_batch_size=64     -> [3008, 3008, 3008, 2976]
   nullable=true,  one null in batch (def levels Materialized) 
write_batch_size=1024   -> [3072, 3072, 3072, 2784]
   nullable=true,  one null in batch (def levels Materialized) 
write_batch_size=100000 -> [12000]
   ```
   
   The first and third groups behave as documented: the row count limit is 
enforced only at mini-batch boundaries, so pages end at the first multiple of 
`write_batch_size` at or past 3,000, and a `write_batch_size` of 100,000 
defeats the limit entirely. The middle group is unchanged across a 12,500x 
range of `write_batch_size`.
   
   ### Expected behavior
   
   Either
   
   - `write_batch_size` continues to bound the mini-batch size on this path, as 
documented (for example by taking the smaller of the two values), or
   - the documentation on `set_write_batch_size`, 
`set_data_page_row_count_limit` and `set_data_page_size_limit` describes the 
real rule: that `write_batch_size` applies only to columns with materialized 
level data, and that a column with uniform or absent levels batches at 
`data_page_row_count_limit` instead.
   
   I have no view on which is the right resolution; the two are inconsistent 
today and I could not tell which was intended from the code.
   
   ### Additional context
   
   `parquet` 59.2.0 (crates.io), also present on `main` @ `0aece99d08`. The 
branch was introduced in #9831.
   
   The substitution is not uniformly coarser than the documented behaviour. 
With the row count limit effectively disabled and 
`set_data_page_size_limit(10_000)` on the same Int64 column, 100,000 rows 
produce:
   
   ```
   nullable=false (Absent)            wbs=8     -> 80 pages, first page 1256 
rows / 10070 bytes
   nullable=false (Absent)            wbs=1024  -> 49 pages, first page 2048 
rows / 16406 bytes
   nullable=true, no nulls (Uniform)  wbs=8     -> 80 pages, first page 1250 
rows / 10029 bytes
   nullable=true, no nulls (Uniform)  wbs=1024  -> 80 pages, first page 1250 
rows / 10029 bytes
   ```
   
   Here the compact path holds the byte limit more tightly than 
`write_batch_size=1024` does, because `ByteBudgetChunker` sizes the sub-batch 
from the page byte budget once `base_batch_size` is large enough to matter. The 
point is not that one branch is worse, but that the knob the documentation 
names as the enforcement granularity is not the one in effect.
   
   ---
   
   *This issue was written by Claude (Anthropic's AI assistant) working with 
@adriangb. The reproduction above was executed and its output is verbatim.*
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to