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

alamb pushed a commit to branch 59_maintenance
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/59_maintenance by this push:
     new 556186a5e5 [59_maintenance] Backport fix for DELTA_BYTE_ARRAY dedup 
with values larger than the page size limit (#10826)
556186a5e5 is described below

commit 556186a5e5a9b3c399ab4841db7f03fdfca0ff4c
Author: Andrew Lamb <[email protected]>
AuthorDate: Tue Aug 25 05:49:12 2026 -0400

    [59_maintenance] Backport fix for DELTA_BYTE_ARRAY dedup with values larger 
than the page size limit (#10826)
    
    # Which issue does this PR close?
    
    - part of https://github.com/apache/arrow-rs/issues/10738
    
    # Rationale for this change
    
    Backport the fix for the DELTA_BYTE_ARRAY dedup regression with values
    larger than the page size limit
    (https://github.com/apache/arrow-rs/issues/10489) to the
    `59_maintenance` branch so it is included in the 59.3.0 release.
    
    # What changes are included in this PR?
    
    Backport / Cherry-pick:
    - https://github.com/apache/arrow-rs/pull/10505
    
    # Are these changes tested?
    
    By CI
    
    # Are there any user-facing changes?
    
    No
    
    Co-authored-by: Adrian Garcia Badaracco 
<[email protected]>
    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 145431c264..6160c67e3f 100644
--- a/parquet/src/arrow/arrow_writer/byte_array.rs
+++ b/parquet/src/arrow/arrow_writer/byte_array.rs
@@ -572,6 +572,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 d9adacff41..b3eaae3008 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;
@@ -324,6 +345,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 aa9cef16c5..b75dbc2e99 100644
--- a/parquet/src/column/writer/mod.rs
+++ b/parquet/src/column/writer/mod.rs
@@ -245,6 +245,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,
     repetition_level_histogram: Option<LevelHistogram>,
     definition_level_histogram: Option<LevelHistogram>,
@@ -272,6 +278,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.repetition_level_histogram
             .as_mut()
@@ -1001,8 +1008,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()?;
         }
@@ -1030,6 +1042,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 {
@@ -1042,7 +1091,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())
     }
 
@@ -2890,6 +2942,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 1c63a31443..15093ed7d2 100644
--- a/parquet/tests/arrow_writer_layout.rs
+++ b/parquet/tests/arrow_writer_layout.rs
@@ -748,6 +748,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