This is an automated email from the ASF dual-hosted git repository.

alamb 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 4ceed4d86a fix(parquet): keep DELTA_BYTE_ARRAY dedup for values larger 
than the page size limit (#10505)
4ceed4d86a is described below

commit 4ceed4d86af4314ddf61ef6267655addba6a0f19
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Mon Aug 24 16:31:18 2026 -0500

    fix(parquet): keep DELTA_BYTE_ARRAY dedup for values larger than the page 
size limit (#10505)
    
    - closes https://github.com/apache/arrow-rs/issues/10489
    - closes https://github.com/apache/arrow-rs/pull/10504
    
    ## The cause is the page flush, not the mini-batch splitting
    
    The issue attributes the regression to #9972's byte-budget sub-batching.
    It's actually the page flush; the sub-batching only exposes it.
    
    `should_add_data_page` fires when `estimated_data_page_size() >=
    data_page_size_limit`. For `DELTA_BYTE_ARRAY` that estimate is the real
    encoded size, and the first value on a page is stored in full — so a
    single 8 MiB value against a 1 MiB limit puts the page over the limit by
    itself and triggers a flush. Flushing clears the encoder's `previous`,
    so the next value gets `prefix_length = 0`, and so on. The encoding
    degenerates to exactly `PLAIN`.
    
    Before #9972 a 1024-row mini-batch meant the check simply didn't run
    until 1024 values had been written, so the dedup survived. That was an
    accident of `write_batch_size`, not a designed property — the same
    column at 2000 rows already lost the prefix at the 1024-value boundary.
    
    This matters for choosing a fix: **making the byte budget encoding-aware
    (suggestion 2 in the issue) does not fix the reported bug.** However
    precise the budget, the first value alone still exceeds the limit and
    still triggers the flush.
    
    ## The fix
    
    Parquet requires at least one value per data page, so a value larger
    than the limit cannot be split out — the limit is unsatisfiable for it.
    Counting those bytes against the limit is what forces the pathological
    one-value-per-page cut.
    
    Record that first value's encoded size in `PageMetrics::page_size_floor`
    and apply the limit to what follows it, i.e. the bytes that *can* still
    go on another page.
    
    The exemption is gated on a new
    `ColumnValueEncoder::compresses_against_previous_value` (default
    `false`, `true` only for `DELTA_BYTE_ARRAY` in both the generic and
    arrow encoders). `PLAIN` and `DELTA_LENGTH_BYTE_ARRAY` cost the same
    wherever a value lands, so there is nothing to preserve by keeping
    values together and they keep their existing tighter one-value page
    bound. All three regression tests from #9972 pass unmodified.
    
    ## Why not "don't split when the split cannot help"
    
    Suggestion 1 in the issue restores the dedup, but by removing the bound
    that #9972 added, for exactly the workload it was added for. Measured
    with `Some(1) => chunk_size` in `byte_budget_chunker.rs`, 2048 rows ×
    128 KiB distinct values against a 64 KiB page limit — the same regime as
    a 10 MB value against the 1 MiB default, scaled to fit in RAM:
    
    | | pages | max page |
    |---|---|---|
    | 59.1.0 | 2048 | 128 KiB |
    | suggestion 1 | 2 | **128 MiB** |
    | this PR | 1024 | 256 KiB |
    
    Page size under suggestion 1 is `write_batch_size × value_size`. At 1024
    × 10 MB that is a 10 GB page, which is the failure #9972 fixed.
    
    ## Results
    
    The reporter's table, reproduced verbatim (10 identical 8 MiB values,
    raw input 80 MiB):
    
    | `data_page_size_limit` | 58.4.0 | 59.1.0 | this PR |
    | --- | --- | --- | --- |
    | default (1 MiB) | 8.00 MiB | 80.00 MiB | **8.00 MiB** |
    | 4 MiB | 8.00 MiB | 80.00 MiB | **8.00 MiB** |
    | 8 MiB | 8.00 MiB | 80.00 MiB | **8.00 MiB** |
    | 16 MiB | 8.00 MiB | 8.00 MiB | **8.00 MiB** |
    
    Values that merely share a long prefix behave the same. Values sharing
    *no* prefix are unaffected in file size and stay bounded at up to two
    values per page — the exempt one plus the one that trips the budget.
    
    ## Tests
    
    -
    `test_column_writer_delta_byte_array_dedups_large_shared_prefix_values`
    — 16 identical 64 KiB values, 16 KiB page limit. Fails on `main` with 1
    MiB across 16 pages (byte for byte what `PLAIN` produces); passes here
    at ~one value's worth.
    -
    `test_column_writer_delta_byte_array_bounds_pages_without_shared_prefix`
    — the same column with values differing from byte 0, asserting pages
    stay bounded by two values. Guards against fixing this by dropping the
    bound.
    - `test_large_string_delta_byte_array_shared_prefix` — the `ArrowWriter`
    path from the report, as an exact page-layout assertion.
    
    ## Notes
    
    One known limitation, pinned by
    `test_column_writer_delta_byte_array_nullable_shared_prefix_partial_dedup`:
    the exemption triggers when a page opens with a mini-batch holding
    exactly one value. That is guaranteed for chunks without nulls (the
    byte-budget chunker gives an over-limit value its own mini-batch), but a
    null in a chunk makes the chunker convert values to levels by ratio and
    round up, so pages can open with a two-value mini-batch, miss the
    exemption, and store their first value in full. Dedup on such columns is
    partial rather than absent, and never worse than `main`, which stores
    every value in full. The clean fix is for the granular path to cut
    windows after an exact value count instead of ratio-scaling; that is
    independent of this change and tracked in #10538. The pinning test
    documents the layout to expect once it lands.
    
    A follow-up worth considering separately: the byte budget in
    `count_within_budget_*` still measures raw payload length, so
    `DELTA_BYTE_ARRAY` columns sub-batch more eagerly than the encoded size
    warrants. That is a throughput question rather than a correctness one,
    and it is not what caused this regression.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 parquet/src/arrow/arrow_writer/byte_array.rs |   8 ++
 parquet/src/column/writer/encoder.rs         |  27 ++++
 parquet/src/column/writer/mod.rs             | 198 ++++++++++++++++++++++++++-
 parquet/tests/arrow_writer/layout.rs         |  40 ++++++
 4 files changed, 272 insertions(+), 1 deletion(-)

diff --git a/parquet/src/arrow/arrow_writer/byte_array.rs 
b/parquet/src/arrow/arrow_writer/byte_array.rs
index 5dbf70cbd8..6346199b16 100644
--- a/parquet/src/arrow/arrow_writer/byte_array.rs
+++ b/parquet/src/arrow/arrow_writer/byte_array.rs
@@ -567,6 +567,14 @@ impl ColumnValueEncoder for ByteArrayEncoder {
         self.dict_encoder.is_some()
     }
 
+    fn compresses_against_previous_value(&self) -> bool {
+        // While dictionary encoding is active the data page holds RLE
+        // indices, which carry no cross-value state; only the DELTA_BYTE_ARRAY
+        // fallback shares prefixes with the preceding value.
+        self.dict_encoder.is_none()
+            && matches!(self.fallback.encoder, FallbackEncoderImpl::Delta { .. 
})
+    }
+
     fn estimated_memory_size(&self) -> usize {
         let encoder_size = match &self.dict_encoder {
             Some(encoder) => encoder.estimated_memory_size(),
diff --git a/parquet/src/column/writer/encoder.rs 
b/parquet/src/column/writer/encoder.rs
index e7b548f0dc..fe644d72e0 100644
--- a/parquet/src/column/writer/encoder.rs
+++ b/parquet/src/column/writer/encoder.rs
@@ -132,6 +132,27 @@ pub trait ColumnValueEncoder {
     /// Returns true if this encoder has a dictionary page
     fn has_dictionary(&self) -> bool;
 
+    /// Returns true if the encoder compresses each value against the value
+    /// immediately before it, within the current page.
+    ///
+    /// For such encodings a page boundary is not free: flushing discards the
+    /// previous value, so the first value of the next page is stored in full.
+    /// [`GenericColumnWriter::should_add_data_page`] uses this to exempt a
+    /// page's mandatory first value from the data page byte limit.
+    ///
+    /// Per encoding:
+    /// * `DELTA_BYTE_ARRAY`: true. Each value is stored as the length of the
+    ///   prefix it shares with its predecessor plus the remaining suffix.
+    /// * Everything else: false, the default. `PLAIN` and
+    ///   `DELTA_LENGTH_BYTE_ARRAY` store a value at the same cost wherever it
+    ///   lands, and a dictionary outlives the pages that index into it, so no
+    ///   page boundary makes a value more expensive.
+    ///
+    /// [`GenericColumnWriter::should_add_data_page`]: 
crate::column::writer::GenericColumnWriter::should_add_data_page
+    fn compresses_against_previous_value(&self) -> bool {
+        false
+    }
+
     /// Returns the estimated total memory usage of the encoder
     ///
     fn estimated_memory_size(&self) -> usize;
@@ -329,6 +350,12 @@ impl<T: DataType> ColumnValueEncoder for 
ColumnValueEncoderImpl<T> {
         self.dict_encoder.is_some()
     }
 
+    fn compresses_against_previous_value(&self) -> bool {
+        // While dictionary encoding is active `self.encoder` is unused: the
+        // data page holds RLE indices, which carry no cross-value state.
+        self.dict_encoder.is_none() && self.encoder.encoding() == 
Encoding::DELTA_BYTE_ARRAY
+    }
+
     fn estimated_memory_size(&self) -> usize {
         let encoder_size = self.encoder.estimated_memory_size();
 
diff --git a/parquet/src/column/writer/mod.rs b/parquet/src/column/writer/mod.rs
index 6bda82d646..6ef18419f2 100644
--- a/parquet/src/column/writer/mod.rs
+++ b/parquet/src/column/writer/mod.rs
@@ -256,6 +256,12 @@ impl ColumnCloseResult {
 struct PageMetrics {
     num_buffered_values: u32,
     num_buffered_rows: u32,
+    /// Encoded bytes that the data page byte limit does not apply to,
+    /// because they belong to the page's mandatory first value and cannot be
+    /// moved elsewhere. Zero unless that value alone exceeded the limit
+    /// *and* the encoding compresses against the preceding value; see
+    /// [`ColumnValueEncoder::compresses_against_previous_value`].
+    page_size_exemption: usize,
     num_page_nulls: u64,
     num_page_nans: Option<u64>,
     repetition_level_histogram: Option<LevelHistogram>,
@@ -284,6 +290,7 @@ impl PageMetrics {
     fn new_page(&mut self) {
         self.num_buffered_values = 0;
         self.num_buffered_rows = 0;
+        self.page_size_exemption = 0;
         self.num_page_nulls = 0;
         self.num_page_nans = None;
         self.repetition_level_histogram
@@ -1033,8 +1040,13 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
             None => self.encoder.write(values, values_offset, 
values_to_write)?,
         }
 
+        let page_was_empty = self.page_metrics.num_buffered_values == 0;
         self.page_metrics.num_buffered_values += num_levels as u32;
 
+        if page_was_empty && values_to_write == 1 {
+            self.set_page_size_exemption();
+        }
+
         if self.should_add_data_page() {
             self.add_data_page()?;
         }
@@ -1062,6 +1074,43 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
         }
     }
 
+    /// Exempt a page's mandatory first value from the data page byte limit,
+    /// when that value alone already exceeds it.
+    ///
+    /// Parquet requires every data page to hold at least one value, so such a
+    /// value cannot be split out no matter how the limit is set. Counting it
+    /// against the limit makes the limit unsatisfiable, and
+    /// [`Self::should_add_data_page`] then cuts a page after every single
+    /// value.
+    ///
+    /// For `DELTA_BYTE_ARRAY` that costs more than the extra pages. A value is
+    /// stored as a suffix of the value before it, and a page boundary resets
+    /// what "the value before it" refers to, so one value per page means every
+    /// value is stored in full: a column of large values sharing long prefixes
+    /// writes exactly the bytes `PLAIN` would
+    /// ([#10489](https://github.com/apache/arrow-rs/issues/10489)).
+    ///
+    /// Only encodings that compress against the preceding value opt in, so
+    /// `PLAIN` and `DELTA_LENGTH_BYTE_ARRAY` keep their tighter one-value page
+    /// bound.
+    ///
+    /// Known limitation: the caller's trigger keys on a page-opening
+    /// mini-batch holding exactly one value. Nulls in a chunk make the
+    /// byte-budget chunker emit multi-level mini-batches, so on nullable
+    /// columns pages that open with a two-value mini-batch miss the
+    /// exemption and dedup is only partial; see
+    /// 
`test_column_writer_delta_byte_array_nullable_shared_prefix_partial_dedup`.
+    #[cold]
+    fn set_page_size_exemption(&mut self) {
+        if !self.encoder.compresses_against_previous_value() {
+            return;
+        }
+        let size = self.encoder.estimated_data_page_size();
+        if size >= self.props.column_data_page_size_limit(self.descr.path()) {
+            self.page_metrics.page_size_exemption = size;
+        }
+    }
+
     /// Returns true if there is enough data for a data page, false otherwise.
     #[inline]
     fn should_add_data_page(&self) -> bool {
@@ -1074,7 +1123,10 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
         }
 
         self.page_metrics.num_buffered_rows as usize >= 
self.props.data_page_row_count_limit()
-            || self.encoder.estimated_data_page_size()
+            || self
+                .encoder
+                .estimated_data_page_size()
+                .saturating_sub(self.page_metrics.page_size_exemption)
                 >= self.props.column_data_page_size_limit(self.descr.path())
     }
 
@@ -2986,6 +3038,150 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_column_writer_delta_byte_array_dedups_large_shared_prefix_values() 
{
+        // Regression for https://github.com/apache/arrow-rs/issues/10489.
+        // 16 identical 64 KiB values against a 16 KiB page limit: every value
+        // is over the limit on its own, and `DELTA_BYTE_ARRAY` should still
+        // dedup them down to about one value's worth of bytes in total.
+        let value_size = 64 * 1024; // 64 KiB per value, > the page limit
+        let page_byte_limit = 16 * 1024;
+        let num_rows = 16;
+
+        let props = WriterProperties::builder()
+            .set_writer_version(WriterVersion::PARQUET_1_0)
+            .set_dictionary_enabled(false)
+            .set_encoding(Encoding::DELTA_BYTE_ARRAY)
+            .set_data_page_size_limit(page_byte_limit)
+            .set_statistics_enabled(EnabledStatistics::None)
+            .build();
+
+        // Identical values: one full value plus `num_rows - 1` zero-length
+        // suffixes is all this column should cost.
+        let data: Vec<_> = (0..num_rows)
+            .map(|_| ByteArray::from(vec![b'a'; value_size]))
+            .collect();
+        let pages = write_and_collect_pages::<ByteArrayType>(props, 0, 0, 
&data, None, None);
+
+        // Every value must still end up somewhere.
+        let total_values: u32 = pages.data_pages.iter().map(|(_, n)| n).sum();
+        assert_eq!(total_values as usize, num_rows);
+
+        // Before the fix this was `num_rows * value_size` — byte for byte
+        // what PLAIN produces, i.e. the encoding doing no work at all.
+        let total_bytes: usize = pages.data_pages.iter().map(|(size, _)| 
size).sum();
+        assert!(
+            total_bytes < 2 * value_size,
+            "expected under 2x a single value ({}B) for {num_rows} identical \
+             values, got {total_bytes}B across pages {:?}",
+            2 * value_size,
+            pages.data_pages,
+        );
+    }
+
+    #[test]
+    fn 
test_column_writer_delta_byte_array_bounds_pages_without_shared_prefix() {
+        // Companion to the test above: same shape, but the values share no
+        // prefix, so there is nothing to dedup and pages must stay bounded
+        // by the value size. This is why the exemption covers one value
+        // rather than dropping the byte budget altogether.
+        let value_size = 64 * 1024;
+        let page_byte_limit = 16 * 1024;
+        let num_rows = 16;
+
+        let props = WriterProperties::builder()
+            .set_writer_version(WriterVersion::PARQUET_1_0)
+            .set_dictionary_enabled(false)
+            .set_encoding(Encoding::DELTA_BYTE_ARRAY)
+            .set_data_page_size_limit(page_byte_limit)
+            .set_statistics_enabled(EnabledStatistics::None)
+            .build();
+
+        // No two values share a prefix: they differ at the first byte.
+        let data: Vec<_> = (0..num_rows)
+            .map(|i| ByteArray::from(vec![i as u8; value_size]))
+            .collect();
+        let pages = write_and_collect_pages::<ByteArrayType>(props, 0, 0, 
&data, None, None);
+
+        let total_values: u32 = pages.data_pages.iter().map(|(_, n)| n).sum();
+        assert_eq!(total_values as usize, num_rows);
+
+        // Expect at most two values per page: the exempted first value plus
+        // one more that trips the budget.
+        let upper_bound = 2 * value_size + 64;
+        for (size, n_values) in &pages.data_pages {
+            assert!(
+                *size <= upper_bound,
+                "page size {size} exceeds two-value bound ({upper_bound}B); 
pages {:?}",
+                pages.data_pages,
+            );
+            assert!(
+                *n_values <= 2,
+                "page holds {n_values} values, expected at most 2; pages {:?}",
+                pages.data_pages,
+            );
+        }
+    }
+
+    #[test]
+    fn 
test_column_writer_delta_byte_array_nullable_shared_prefix_partial_dedup() {
+        // Documents the *current* behavior of the first-value exemption on a
+        // nullable column; this pins a known limitation, not an ideal.
+        //
+        // The exemption fires when a page's first mini-batch contains exactly
+        // one value. For a non-nullable column the byte-budget chunker gives
+        // an over-limit value a one-level mini-batch, so that always holds.
+        // One null in the chunk changes the level:value ratio to 17:16, the
+        // chunker rounds up to two-level mini-batches, and a page whose first
+        // mini-batch carries two values misses the exemption: it is cut after
+        // those two values, and its first value is stored in full.
+        //
+        // The one mini-batch that pairs the null with a value has a single
+        // value, so the page it opens does get the exemption and accumulates
+        // every remaining suffix. The result for 16 identical values with a
+        // null at index 8 is four two-value pages (each storing one value in
+        // full), then one exempt page holding the rest:
+        //
+        //   values per page: [2, 2, 2, 2, 9]  (counts include the null level)
+        //   total bytes:     ~5 full values, vs ~1 ideally and 16 for PLAIN
+        //
+        // If the exemption trigger is ever keyed on values written to the
+        // page (0 -> 1) instead of mini-batch shape, this test should fail
+        // with fewer, larger pages — update it to pin the improved layout.
+        let value_size = 64 * 1024;
+        let page_byte_limit = 16 * 1024;
+        let num_values = 16;
+
+        let props = WriterProperties::builder()
+            .set_writer_version(WriterVersion::PARQUET_1_0)
+            .set_dictionary_enabled(false)
+            .set_encoding(Encoding::DELTA_BYTE_ARRAY)
+            .set_data_page_size_limit(page_byte_limit)
+            .set_statistics_enabled(EnabledStatistics::None)
+            .build();
+
+        let data: Vec<_> = (0..num_values)
+            .map(|_| ByteArray::from(vec![b'a'; value_size]))
+            .collect();
+        // 17 levels: a null at index 8, values everywhere else.
+        let def_levels: Vec<i16> = (0..num_values as i16 + 1)
+            .map(|i| i16::from(i != 8))
+            .collect();
+        let pages =
+            write_and_collect_pages::<ByteArrayType>(props, 1, 0, &data, 
Some(&def_levels), None);
+
+        let per_page_values: Vec<u32> = pages.data_pages.iter().map(|(_, n)| 
*n).collect();
+        assert_eq!(per_page_values, vec![2, 2, 2, 2, 9]);
+
+        let total_bytes: usize = pages.data_pages.iter().map(|(size, _)| 
size).sum();
+        assert!(
+            total_bytes > 4 * value_size && total_bytes < 6 * value_size,
+            "expected ~5 full values' worth of bytes (partial dedup), \
+             got {total_bytes}B across pages {:?}",
+            pages.data_pages,
+        );
+    }
+
     #[test]
     fn test_column_writer_caps_page_size_for_large_values_in_list() {
         // Coverage for the Materialized-rep branch of
diff --git a/parquet/tests/arrow_writer/layout.rs 
b/parquet/tests/arrow_writer/layout.rs
index 55a489272f..04f0a9cbed 100644
--- a/parquet/tests/arrow_writer/layout.rs
+++ b/parquet/tests/arrow_writer/layout.rs
@@ -755,6 +755,46 @@ fn test_large_string() {
     });
 }
 
+#[test]
+fn test_large_string_delta_byte_array_shared_prefix() {
+    // Regression for https://github.com/apache/arrow-rs/issues/10489, at the
+    // `ArrowWriter` level the report used.
+    //
+    // Same shape as `test_large_string` — 64 KiB values against a 16 KiB
+    // page limit — but `DELTA_BYTE_ARRAY` and 32 identical values. Expect a
+    // single page holding all 32 rows and about one value's worth of bytes,
+    // rather than the 32 pages and ~2 MiB `PLAIN` produces.
+    let value_size = 64 * 1024;
+    let strings: Vec<String> = (0..32).map(|_| 
"x".repeat(value_size)).collect();
+    let array = Arc::new(StringArray::from(strings)) as _;
+    let batch = RecordBatch::try_from_iter([("col", array)]).unwrap();
+    let props = WriterProperties::builder()
+        .set_dictionary_enabled(false)
+        .set_encoding(Encoding::DELTA_BYTE_ARRAY)
+        .set_data_page_size_limit(16 * 1024)
+        .set_statistics_enabled(EnabledStatistics::None)
+        .build();
+
+    do_test(LayoutTest {
+        props,
+        batches: vec![batch],
+        layout: Layout {
+            row_groups: vec![RowGroup {
+                columns: vec![ColumnChunk {
+                    pages: vec![Page {
+                        rows: 32,
+                        page_header_size: 21,
+                        compressed_size: 65696,
+                        encoding: Encoding::DELTA_BYTE_ARRAY,
+                        page_type: PageType::DATA_PAGE,
+                    }],
+                    dictionary_page: None,
+                }],
+            }],
+        },
+    });
+}
+
 #[test]
 fn test_large_string_view() {
     // Same bytes and expected layout as `test_large_string`, but the input

Reply via email to