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 5fa7c8cc10 perf(parquet): resolve per-column writer properties once 
per column (#10880)
5fa7c8cc10 is described below

commit 5fa7c8cc10cd821b77e0ea9d9444352b28942c8a
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Mon Aug 31 01:58:17 2026 -0500

    perf(parquet): resolve per-column writer properties once per column (#10880)
    
    # Which issue does this PR close?
    
    - Contributes to #9722.
    - Builds on #10878, which adds the `repeated_batches` benchmark cases
    used below.
    
    # Rationale for this change
    
    `WriterProperties` keeps per-column overrides in a
    `HashMap<ColumnPath, ColumnProperties>`. Every accessor that takes a
    `&ColumnPath` — `compression`, `encoding`, `dictionary_enabled`,
    `statistics_enabled`, `write_page_header_statistics`,
    `column_data_page_size_limit`, `column_dictionary_page_size_limit`,
    `column_data_page_v2_compression_ratio_threshold` and
    `bloom_filter_properties` — hashes that path and searches the map.
    `ColumnPath`
    is a `Vec<String>`, so each search hashes every part of the path and
    then
    compares strings on a hit.
    
    The column writers call those accessors repeatedly for the same leaf
    column:
    
    - 6 times while a column writer is constructed
    (`GenericColumnWriter::new` plus
      `ColumnValueEncoder::try_new`);
    - 2 more on every `write`, in `ByteBudgetChunker::new`;
    - 1 per mini-batch in `should_add_data_page`, and another in
      `should_dict_fallback` when a dictionary encoder is active;
    - 1 per page in `add_data_page`, plus one more for Data Page v2.
    
    `WriterProperties` is immutable once built, so all of that recomputes an
    answer
    that was already available when the column writer was created.
    
    Counting the searches directly (temporary instrumentation on the map
    access) over
    the `writer_overhead` shapes:
    
    | benchmark case | searches before | searches after |
    | --- | --- | --- |
    | `writer_overhead/1000_cols` | 10,000 | 1,000 |
    | `writer_overhead/5000_cols` | 50,000 | 5,000 |
    | `writer_overhead/10000_cols` | 100,000 | 10,000 |
    | `writer_overhead/1000_cols/repeated_batches` | 103,000 | 1,000 |
    | `writer_overhead/5000_cols/repeated_batches` | 515,000 | 5,000 |
    
    After the change it is exactly one search per leaf column per row group.
    
    # What changes are included in this PR?
    
    - Adds `WriterProperties::resolve_column_properties`, which searches the
    per-column map once and returns a `ResolvedColumnProperties` holding
    every
    per-column setting already resolved against the file-wide defaults. The
    struct
      is `pub(crate)`; no public API is added.
    - `GenericColumnWriter::new` calls it once and stores the result. The
    per-batch
    and per-page paths, `ByteBudgetChunker::new`, and both
    `ColumnValueEncoder`
      implementations read from it instead of searching the map again.
    `ColumnValueEncoder::try_new` and `create_bloom_filter` now take the
    resolved
      settings; that trait is not nameable outside the crate.
    - The existing per-column accessors keep their behaviour and are
    reimplemented on
    top of the same resolution helpers, so the "column override, else file
    default,
      else constant" rule has one definition rather than one per accessor.
    
    # Are these changes tested?
    
    Yes.
    
    - The existing suite passes unchanged, with and without `encryption`:
    `cargo test -p parquet --features "arrow async encryption test_common
    experimental"`.
    - New `test_resolve_column_properties_matches_individual_accessors`
    asserts the
    one-pass resolution agrees with every individual accessor, for a column
    with
    overrides, a column that inherits the file defaults, and properties left
      entirely at their defaults.
    - To check the writer is byte-for-byte unchanged I wrote the same file
    from a
    build before and after this change, with a workload covering the
    settings this
    PR touches: per-column compression, encoding, dictionary on and off,
    page and
    dictionary page size limits, page-header statistics, a bloom filter,
    dictionary
    fallback on multi-KB values, and both writer versions. The two files are
      identical.
    
    Benchmark:
    
        cargo bench -p parquet --bench writer_overhead
    
    I did not have an idle machine, and criterion's wall-clock intervals
    were too
    wide there to be usable. I measured CPU time (user + sys) per iteration
    instead,
    which is far less sensitive to competing load: the minimum over 40
    alternating
    runs of the two builds, with a separately measured ~13.3 ms of fixed
    benchmark
    setup subtracted.
    
    | benchmark case | before | after | |
    | --- | --- | --- | --- |
    | `writer_overhead/1000_cols` | 2.7 ms | 2.1 ms | see note |
    | `writer_overhead/5000_cols` | 20.2 ms | 18.5 ms | −8 % |
    | `writer_overhead/10000_cols` | 38.8 ms | 34.2 ms | −12 % |
    | `writer_overhead/1000_cols/repeated_batches` | 12.9 ms | 8.6 ms | −34
    % |
    | `writer_overhead/5000_cols/repeated_batches` | 88.0 ms | 70.1 ms | −20
    % |
    
    Note: at 1,000 columns with a single batch the remaining figure is close
    enough
    to the spread of the setup subtraction that I would not read a
    percentage into
    it. The other four imply about 40 ns per map search, consistently across
    all four
    shapes, which matches the search counts above. A run on an idle machine
    would be
    worth having, and the benchmark from #10878 makes that easy.
    
    `ColumnWriterImpl<Int32Type>` grows from 1312 to 1376 bytes to hold the
    resolved
    settings. Peak RSS on the 10,000 column benchmark was unchanged.
    
    # Are there any user-facing changes?
    
    No. No public API is added, removed or changed, and the bytes written
    are
    unchanged.
    
    # AI usage
    
    This PR was written with Claude Code and reviewed by a human. The
    byte-for-byte
    output comparison and the map-search counts above are the checks run to
    confirm
    the behaviour is unchanged.
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 parquet/src/arrow/arrow_writer/byte_array.rs     |  38 +--
 parquet/src/column/writer/byte_budget_chunker.rs |   8 +-
 parquet/src/column/writer/encoder.rs             |  40 ++-
 parquet/src/column/writer/mod.rs                 |  39 ++-
 parquet/src/file/properties.rs                   | 302 +++++++++++++++++++----
 5 files changed, 326 insertions(+), 101 deletions(-)

diff --git a/parquet/src/arrow/arrow_writer/byte_array.rs 
b/parquet/src/arrow/arrow_writer/byte_array.rs
index 85f37d6da6..e6f843f959 100644
--- a/parquet/src/arrow/arrow_writer/byte_array.rs
+++ b/parquet/src/arrow/arrow_writer/byte_array.rs
@@ -24,7 +24,9 @@ use crate::data_type::{AsBytes, ByteArray, Int32Type};
 use crate::encodings::encoding::{DeltaBitPackEncoder, Encoder};
 use crate::encodings::rle::RleEncoder;
 use crate::errors::{ParquetError, Result};
-use crate::file::properties::{EnabledStatistics, WriterProperties, 
WriterVersion};
+use crate::file::properties::{
+    EnabledStatistics, ResolvedColumnProperties, WriterProperties, 
WriterVersion,
+};
 use crate::geospatial::accumulator::{GeoStatsAccumulator, 
try_new_geo_stats_accumulator};
 use crate::geospatial::statistics::GeospatialStatistics;
 use crate::schema::types::ColumnDescPtr;
@@ -135,16 +137,16 @@ enum FallbackEncoderImpl {
 }
 
 impl FallbackEncoder {
-    /// Create the fallback encoder for the given [`ColumnDescPtr`] and 
[`WriterProperties`]
-    fn new(descr: &ColumnDescPtr, props: &WriterProperties) -> Result<Self> {
+    /// Create the fallback encoder for the given [`WriterProperties`] and the
+    /// column settings already resolved from them
+    fn new(props: &WriterProperties, column_props: &ResolvedColumnProperties) 
-> Result<Self> {
         // Set either main encoder or fallback encoder.
-        let encoding =
-            props
-                .encoding(descr.path())
-                .unwrap_or_else(|| match props.writer_version() {
-                    WriterVersion::PARQUET_1_0 => Encoding::PLAIN,
-                    WriterVersion::PARQUET_2_0 => Encoding::DELTA_BYTE_ARRAY,
-                });
+        let encoding = column_props
+            .encoding
+            .unwrap_or_else(|| match props.writer_version() {
+                WriterVersion::PARQUET_1_0 => Encoding::PLAIN,
+                WriterVersion::PARQUET_2_0 => Encoding::DELTA_BYTE_ARRAY,
+            });
 
         let encoder = match encoding {
             Encoding::PLAIN => FallbackEncoderImpl::Plain { buffer: vec![] },
@@ -441,19 +443,21 @@ impl ColumnValueEncoder for ByteArrayEncoder {
         Some(sbbf)
     }
 
-    fn try_new(descr: &ColumnDescPtr, props: &WriterProperties) -> Result<Self>
+    fn try_new(
+        descr: &ColumnDescPtr,
+        props: &WriterProperties,
+        column_props: &ResolvedColumnProperties,
+    ) -> Result<Self>
     where
         Self: Sized,
     {
-        let dictionary = props
-            .dictionary_enabled(descr.path())
-            .then(DictEncoder::default);
+        let dictionary = 
column_props.dictionary_enabled.then(DictEncoder::default);
 
-        let fallback = FallbackEncoder::new(descr, props)?;
+        let fallback = FallbackEncoder::new(props, column_props)?;
 
-        let (bloom_filter, bloom_filter_target_fpp) = 
create_bloom_filter(props, descr)?;
+        let (bloom_filter, bloom_filter_target_fpp) = 
create_bloom_filter(column_props)?;
 
-        let statistics_enabled = props.statistics_enabled(descr.path());
+        let statistics_enabled = column_props.statistics_enabled;
 
         let geo_stats_accumulator = try_new_geo_stats_accumulator(descr);
 
diff --git a/parquet/src/column/writer/byte_budget_chunker.rs 
b/parquet/src/column/writer/byte_budget_chunker.rs
index 56c7b4c6f8..0d8eeb85ab 100644
--- a/parquet/src/column/writer/byte_budget_chunker.rs
+++ b/parquet/src/column/writer/byte_budget_chunker.rs
@@ -20,7 +20,7 @@
 use crate::basic::Type;
 use crate::column::writer::LevelDataRef;
 use crate::column::writer::encoder::ColumnValueEncoder;
-use crate::file::properties::WriterProperties;
+use crate::file::properties::ResolvedColumnProperties;
 use crate::schema::types::ColumnDescriptor;
 
 /// Picks byte-budget-aware mini-batch sizes for one column.
@@ -61,11 +61,11 @@ impl ByteBudgetChunker {
     #[inline]
     pub(crate) fn new(
         descr: &ColumnDescriptor,
-        props: &WriterProperties,
+        column_props: &ResolvedColumnProperties,
         base_batch_size: usize,
     ) -> Self {
-        let page_byte_limit = props.column_data_page_size_limit(descr.path());
-        let dict_page_byte_limit = 
props.column_dictionary_page_size_limit(descr.path());
+        let page_byte_limit = column_props.data_page_size_limit;
+        let dict_page_byte_limit = column_props.dictionary_page_size_limit;
         let static_bytes_per_value = match descr.physical_type() {
             Type::BOOLEAN => Some(1),
             Type::INT32 | Type::FLOAT => Some(std::mem::size_of::<i32>()),
diff --git a/parquet/src/column/writer/encoder.rs 
b/parquet/src/column/writer/encoder.rs
index fe644d72e0..467c1510b5 100644
--- a/parquet/src/column/writer/encoder.rs
+++ b/parquet/src/column/writer/encoder.rs
@@ -26,7 +26,7 @@ use crate::data_type::DataType;
 use crate::data_type::private::ParquetValueType;
 use crate::encodings::encoding::{DictEncoder, Encoder, get_encoder};
 use crate::errors::{ParquetError, Result};
-use crate::file::properties::{EnabledStatistics, WriterProperties};
+use crate::file::properties::{EnabledStatistics, ResolvedColumnProperties, 
WriterProperties};
 use crate::geospatial::accumulator::{GeoStatsAccumulator, 
try_new_geo_stats_accumulator};
 use crate::geospatial::statistics::GeospatialStatistics;
 use crate::schema::types::{BasicTypeInfo, ColumnDescPtr};
@@ -80,7 +80,18 @@ pub trait ColumnValueEncoder {
     type Values: ColumnValues + ?Sized;
 
     /// Create a new [`ColumnValueEncoder`]
-    fn try_new(descr: &ColumnDescPtr, props: &WriterProperties) -> Result<Self>
+    ///
+    /// `column_props` holds the settings in `props` that apply specifically to
+    /// `descr`, already resolved by the caller.
+    #[expect(
+        private_interfaces,
+        reason = "this trait is not nameable outside the crate"
+    )]
+    fn try_new(
+        descr: &ColumnDescPtr,
+        props: &WriterProperties,
+        column_props: &ResolvedColumnProperties,
+    ) -> Result<Self>
     where
         Self: Sized;
 
@@ -254,22 +265,30 @@ impl<T: DataType> ColumnValueEncoder for 
ColumnValueEncoderImpl<T> {
         Some(sbbf)
     }
 
-    fn try_new(descr: &ColumnDescPtr, props: &WriterProperties) -> 
Result<Self> {
-        let dict_supported = props.dictionary_enabled(descr.path())
+    #[expect(
+        private_interfaces,
+        reason = "this trait is not nameable outside the crate"
+    )]
+    fn try_new(
+        descr: &ColumnDescPtr,
+        props: &WriterProperties,
+        column_props: &ResolvedColumnProperties,
+    ) -> Result<Self> {
+        let dict_supported = column_props.dictionary_enabled
             && has_dictionary_support(T::get_physical_type(), props);
         let dict_encoder = dict_supported.then(|| 
DictEncoder::new(descr.clone()));
 
         // Set either main encoder or fallback encoder.
         let encoder = get_encoder(
-            props
-                .encoding(descr.path())
+            column_props
+                .encoding
                 .unwrap_or_else(|| fallback_encoding(T::get_physical_type(), 
props)),
             descr,
         )?;
 
-        let statistics_enabled = props.statistics_enabled(descr.path());
+        let statistics_enabled = column_props.statistics_enabled;
 
-        let (bloom_filter, bloom_filter_target_fpp) = 
create_bloom_filter(props, descr)?;
+        let (bloom_filter, bloom_filter_target_fpp) = 
create_bloom_filter(column_props)?;
 
         let geo_stats_accumulator = try_new_geo_stats_accumulator(descr);
 
@@ -477,10 +496,9 @@ where
 /// Creates a bloom filter sized for the column's configured NDV, returning 
the filter
 /// and the target FPP for folding.
 pub(crate) fn create_bloom_filter(
-    props: &WriterProperties,
-    descr: &ColumnDescPtr,
+    column_props: &ResolvedColumnProperties,
 ) -> Result<(Option<Sbbf>, f64)> {
-    match props.bloom_filter_properties(descr.path()) {
+    match column_props.bloom_filter_properties.as_ref() {
         Some(bf_props) => Ok((
             Some(Sbbf::new_with_ndv_fpp(bf_props.ndv(), bf_props.fpp())?),
             bf_props.fpp(),
diff --git a/parquet/src/column/writer/mod.rs b/parquet/src/column/writer/mod.rs
index c0b7ee04ad..a91d2af53d 100644
--- a/parquet/src/column/writer/mod.rs
+++ b/parquet/src/column/writer/mod.rs
@@ -45,7 +45,8 @@ use crate::file::metadata::{
     OffsetIndexBuilder, PageEncodingStats,
 };
 use crate::file::properties::{
-    EnabledStatistics, WriterProperties, WriterPropertiesPtr, WriterVersion,
+    EnabledStatistics, ResolvedColumnProperties, WriterProperties, 
WriterPropertiesPtr,
+    WriterVersion,
 };
 use crate::file::statistics::{Statistics, ValueStatistics};
 use crate::schema::types::{BasicTypeInfo, ColumnDescPtr, ColumnDescriptor};
@@ -457,7 +458,10 @@ pub struct GenericColumnWriter<'a, E: ColumnValueEncoder> {
     // Column writer properties
     descr: ColumnDescPtr,
     props: WriterPropertiesPtr,
-    statistics_enabled: EnabledStatistics,
+    /// Per-column settings for [`Self::descr`], resolved once here so that the
+    /// per-batch and per-page write paths never search the per-column override
+    /// map in `props` again.
+    column_props: ResolvedColumnProperties,
 
     page_writer: Box<dyn PageWriter + 'a>,
     codec: Compression,
@@ -499,12 +503,13 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
         props: WriterPropertiesPtr,
         page_writer: Box<dyn PageWriter + 'a>,
     ) -> Self {
-        let codec = props.compression(descr.path());
+        let column_props = props.resolve_column_properties(descr.path());
+        let codec = column_props.compression;
         let codec_options = CodecOptionsBuilder::default().build();
         let compressor = create_codec(codec, &codec_options).unwrap();
-        let encoder = E::try_new(&descr, props.as_ref()).unwrap();
+        let encoder = E::try_new(&descr, props.as_ref(), 
&column_props).unwrap();
 
-        let statistics_enabled = props.statistics_enabled(descr.path());
+        let statistics_enabled = column_props.statistics_enabled;
 
         let mut encodings = BTreeSet::new();
         // Used for level information
@@ -540,7 +545,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, E> {
             rep_levels_encoder: 
Self::create_level_encoder(descr.max_rep_level(), &props),
             descr,
             props,
-            statistics_enabled,
+            column_props,
             page_writer,
             codec,
             compressor,
@@ -636,7 +641,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, E> {
         };
         debug_assert!(base_batch_size > 0);
 
-        let chunker = ByteBudgetChunker::new(&self.descr, &self.props, 
base_batch_size);
+        let chunker = ByteBudgetChunker::new(&self.descr, &self.column_props, 
base_batch_size);
         while levels_offset < num_levels {
             let mut end_offset = num_levels.min(levels_offset + 
base_batch_size);
 
@@ -1065,11 +1070,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
     #[inline]
     fn should_dict_fallback(&self) -> bool {
         match self.encoder.estimated_dict_page_size() {
-            Some(size) => {
-                size >= self
-                    .props
-                    .column_dictionary_page_size_limit(self.descr.path())
-            }
+            Some(size) => size >= self.column_props.dictionary_page_size_limit,
             None => false,
         }
     }
@@ -1106,7 +1107,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
             return;
         }
         let size = self.encoder.estimated_data_page_size();
-        if size >= self.props.column_data_page_size_limit(self.descr.path()) {
+        if size >= self.column_props.data_page_size_limit {
             self.page_metrics.page_size_exemption = size;
         }
     }
@@ -1127,7 +1128,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
                 .encoder
                 .estimated_data_page_size()
                 .saturating_sub(self.page_metrics.page_size_exemption)
-                >= self.props.column_data_page_size_limit(self.descr.path())
+                >= self.column_props.data_page_size_limit
     }
 
     /// Performs dictionary fallback.
@@ -1417,7 +1418,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
                 update_min(&self.descr, &min, &mut 
self.column_metrics.min_column_value);
                 update_max(&self.descr, &max, &mut 
self.column_metrics.max_column_value);
 
-                (self.statistics_enabled == EnabledStatistics::Page).then_some(
+                (self.column_props.statistics_enabled == 
EnabledStatistics::Page).then_some(
                     ValueStatistics::new(
                         Some(min),
                         Some(max),
@@ -1445,7 +1446,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
 
         // From here on, we only need page statistics if they will be written 
to the page header.
         let page_statistics = page_statistics
-            .filter(|_| 
self.props.write_page_header_statistics(self.descr.path()))
+            .filter(|_| self.column_props.write_page_header_statistics)
             .map(|stats| self.truncate_statistics(Statistics::from(stats)));
 
         let compressed_page = match self.props.writer_version() {
@@ -1509,9 +1510,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
                         let buffer_len = buffer.len();
                         cmpr.compress(&values_data.buf, &mut buffer)?;
                         let compressed_values_size = buffer.len() - buffer_len;
-                        let threshold = self
-                            .props
-                            
.column_data_page_v2_compression_ratio_threshold(self.descr.path());
+                        let threshold = 
self.column_props.data_page_v2_compression_ratio_threshold;
                         if (compressed_values_size as f64) >= 
(uncompressed_size as f64) * threshold
                         {
                             buffer.truncate(buffer_len);
@@ -1599,7 +1598,7 @@ impl<'a, E: ColumnValueEncoder> GenericColumnWriter<'a, 
E> {
             .set_data_page_offset(data_page_offset)
             .set_dictionary_page_offset(dict_page_offset);
 
-        if self.statistics_enabled != EnabledStatistics::None {
+        if self.column_props.statistics_enabled != EnabledStatistics::None {
             let backwards_compatible_min_max = 
self.descr.sort_order().is_signed();
 
             let distinct_count = self
diff --git a/parquet/src/file/properties.rs b/parquet/src/file/properties.rs
index b6369f1ace..8e3895c5a5 100644
--- a/parquet/src/file/properties.rs
+++ b/parquet/src/file/properties.rs
@@ -306,11 +306,7 @@ impl WriterProperties {
     ///
     /// Note: this is a best effort limit based on the write batch size.
     pub fn column_data_page_size_limit(&self, col: &ColumnPath) -> usize {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.data_page_size_limit())
-            .or_else(|| self.default_column_properties.data_page_size_limit())
-            .unwrap_or(DEFAULT_PAGE_SIZE)
+        resolve_data_page_size_limit(self.column_override(col), 
&self.default_column_properties)
     }
 
     /// Returns dictionary page size limit.
@@ -326,11 +322,10 @@ impl WriterProperties {
 
     /// Returns dictionary page size limit for a specific column.
     pub fn column_dictionary_page_size_limit(&self, col: &ColumnPath) -> usize 
{
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.dictionary_page_size_limit())
-            .or_else(|| 
self.default_column_properties.dictionary_page_size_limit())
-            .unwrap_or(DEFAULT_DICTIONARY_PAGE_SIZE_LIMIT)
+        resolve_dictionary_page_size_limit(
+            self.column_override(col),
+            &self.default_column_properties,
+        )
     }
 
     /// Returns the maximum page row count
@@ -473,14 +468,10 @@ impl WriterProperties {
     ///
     /// Takes precedence over 
[`Self::data_page_v2_compression_ratio_threshold`].
     pub fn column_data_page_v2_compression_ratio_threshold(&self, col: 
&ColumnPath) -> f64 {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.data_page_v2_compression_ratio_threshold())
-            .or_else(|| {
-                self.default_column_properties
-                    .data_page_v2_compression_ratio_threshold()
-            })
-            .unwrap_or(DEFAULT_DATA_PAGE_V2_COMPRESSION_RATIO_THRESHOLD)
+        resolve_data_page_v2_compression_ratio_threshold(
+            self.column_override(col),
+            &self.default_column_properties,
+        )
     }
 
     /// Returns encoding for a data page, when dictionary encoding is enabled.
@@ -510,43 +501,28 @@ impl WriterProperties {
     /// If encoding is not set, then column writer will choose the best 
encoding
     /// based on the column type.
     pub fn encoding(&self, col: &ColumnPath) -> Option<Encoding> {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.encoding())
-            .or_else(|| self.default_column_properties.encoding())
+        resolve_encoding(self.column_override(col), 
&self.default_column_properties)
     }
 
     /// Returns compression codec for a column.
     ///
     /// For more details see 
[`WriterPropertiesBuilder::set_column_compression`]
     pub fn compression(&self, col: &ColumnPath) -> Compression {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.compression())
-            .or_else(|| self.default_column_properties.compression())
-            .unwrap_or(DEFAULT_COMPRESSION)
+        resolve_compression(self.column_override(col), 
&self.default_column_properties)
     }
 
     /// Returns `true` if dictionary encoding is enabled for a column.
     ///
     /// For more details see 
[`WriterPropertiesBuilder::set_dictionary_enabled`]
     pub fn dictionary_enabled(&self, col: &ColumnPath) -> bool {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.dictionary_enabled())
-            .or_else(|| self.default_column_properties.dictionary_enabled())
-            .unwrap_or(DEFAULT_DICTIONARY_ENABLED)
+        resolve_dictionary_enabled(self.column_override(col), 
&self.default_column_properties)
     }
 
     /// Returns which statistics are written for a column.
     ///
     /// For more details see 
[`WriterPropertiesBuilder::set_statistics_enabled`]
     pub fn statistics_enabled(&self, col: &ColumnPath) -> EnabledStatistics {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.statistics_enabled())
-            .or_else(|| self.default_column_properties.statistics_enabled())
-            .unwrap_or(DEFAULT_STATISTICS_ENABLED)
+        resolve_statistics_enabled(self.column_override(col), 
&self.default_column_properties)
     }
 
     /// Returns `true` if [`Statistics`] are to be written to the page header 
for a column.
@@ -555,14 +531,10 @@ impl WriterProperties {
     ///
     /// [`Statistics`]: crate::file::statistics::Statistics
     pub fn write_page_header_statistics(&self, col: &ColumnPath) -> bool {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.write_page_header_statistics())
-            .or_else(|| {
-                self.default_column_properties
-                    .write_page_header_statistics()
-            })
-            .unwrap_or(DEFAULT_WRITE_PAGE_HEADER_STATISTICS)
+        resolve_write_page_header_statistics(
+            self.column_override(col),
+            &self.default_column_properties,
+        )
     }
 
     /// Returns the [`BloomFilterProperties`] for the given column
@@ -571,10 +543,41 @@ impl WriterProperties {
     ///
     /// For more details see 
[`WriterPropertiesBuilder::set_column_bloom_filter_enabled`]
     pub fn bloom_filter_properties(&self, col: &ColumnPath) -> 
Option<&BloomFilterProperties> {
-        self.column_properties
-            .get(col)
-            .and_then(|c| c.bloom_filter_properties())
-            .or_else(|| 
self.default_column_properties.bloom_filter_properties())
+        resolve_bloom_filter_properties(self.column_override(col), 
&self.default_column_properties)
+    }
+
+    /// Returns the per-column override entry for `col`, if any.
+    ///
+    /// This is the only place the per-column map is searched. Searching it 
hashes
+    /// `col`, which is a `Vec<String>`, so callers that need more than one 
setting
+    /// should go through [`Self::resolve_column_properties`] rather than call
+    /// several single-setting accessors.
+    #[inline]
+    fn column_override(&self, col: &ColumnPath) -> Option<&ColumnProperties> {
+        self.column_properties.get(col)
+    }
+
+    /// Resolves every per-column writer setting for `col` with a single 
search of
+    /// the per-column override map.
+    ///
+    /// A column writer needs most of these settings, and needs some of them 
again
+    /// on every batch and every page, so it resolves them once when it is 
created
+    /// and reads the result from then on.
+    pub(crate) fn resolve_column_properties(&self, col: &ColumnPath) -> 
ResolvedColumnProperties {
+        let column = self.column_override(col);
+        let default = &self.default_column_properties;
+        ResolvedColumnProperties {
+            encoding: resolve_encoding(column, default),
+            compression: resolve_compression(column, default),
+            dictionary_enabled: resolve_dictionary_enabled(column, default),
+            statistics_enabled: resolve_statistics_enabled(column, default),
+            write_page_header_statistics: 
resolve_write_page_header_statistics(column, default),
+            data_page_size_limit: resolve_data_page_size_limit(column, 
default),
+            dictionary_page_size_limit: 
resolve_dictionary_page_size_limit(column, default),
+            data_page_v2_compression_ratio_threshold:
+                resolve_data_page_v2_compression_ratio_threshold(column, 
default),
+            bloom_filter_properties: resolve_bloom_filter_properties(column, 
default).cloned(),
+        }
     }
 
     /// Return file encryption properties
@@ -1805,6 +1808,128 @@ impl ColumnProperties {
     }
 }
 
+/// Every per-column writer setting for one leaf column, resolved against the
+/// per-column overrides and the file-wide defaults.
+///
+/// Built by [`WriterProperties::resolve_column_properties`].
+#[derive(Debug, Clone, PartialEq)]
+pub(crate) struct ResolvedColumnProperties {
+    /// See [`WriterProperties::encoding`].
+    pub(crate) encoding: Option<Encoding>,
+    /// See [`WriterProperties::compression`].
+    pub(crate) compression: Compression,
+    /// See [`WriterProperties::dictionary_enabled`].
+    pub(crate) dictionary_enabled: bool,
+    /// See [`WriterProperties::statistics_enabled`].
+    pub(crate) statistics_enabled: EnabledStatistics,
+    /// See [`WriterProperties::write_page_header_statistics`].
+    pub(crate) write_page_header_statistics: bool,
+    /// See [`WriterProperties::column_data_page_size_limit`].
+    pub(crate) data_page_size_limit: usize,
+    /// See [`WriterProperties::column_dictionary_page_size_limit`].
+    pub(crate) dictionary_page_size_limit: usize,
+    /// See 
[`WriterProperties::column_data_page_v2_compression_ratio_threshold`].
+    pub(crate) data_page_v2_compression_ratio_threshold: f64,
+    /// See [`WriterProperties::bloom_filter_properties`].
+    pub(crate) bloom_filter_properties: Option<BloomFilterProperties>,
+}
+
+/// Returns the setting read by `get` for `column` if it sets one, otherwise 
the
+/// setting on `default`.
+///
+/// `column` is the per-column override entry, if the column has one.
+#[inline]
+fn column_or_default<T>(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+    get: impl Fn(&ColumnProperties) -> Option<T>,
+) -> Option<T> {
+    column.and_then(&get).or_else(|| get(default))
+}
+
+fn resolve_encoding(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> Option<Encoding> {
+    column_or_default(column, default, ColumnProperties::encoding)
+}
+
+fn resolve_compression(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> Compression {
+    column_or_default(column, default, 
ColumnProperties::compression).unwrap_or(DEFAULT_COMPRESSION)
+}
+
+fn resolve_dictionary_enabled(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> bool {
+    column_or_default(column, default, ColumnProperties::dictionary_enabled)
+        .unwrap_or(DEFAULT_DICTIONARY_ENABLED)
+}
+
+fn resolve_statistics_enabled(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> EnabledStatistics {
+    column_or_default(column, default, ColumnProperties::statistics_enabled)
+        .unwrap_or(DEFAULT_STATISTICS_ENABLED)
+}
+
+fn resolve_write_page_header_statistics(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> bool {
+    column_or_default(
+        column,
+        default,
+        ColumnProperties::write_page_header_statistics,
+    )
+    .unwrap_or(DEFAULT_WRITE_PAGE_HEADER_STATISTICS)
+}
+
+fn resolve_data_page_size_limit(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> usize {
+    column_or_default(column, default, ColumnProperties::data_page_size_limit)
+        .unwrap_or(DEFAULT_PAGE_SIZE)
+}
+
+fn resolve_dictionary_page_size_limit(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> usize {
+    column_or_default(
+        column,
+        default,
+        ColumnProperties::dictionary_page_size_limit,
+    )
+    .unwrap_or(DEFAULT_DICTIONARY_PAGE_SIZE_LIMIT)
+}
+
+fn resolve_data_page_v2_compression_ratio_threshold(
+    column: Option<&ColumnProperties>,
+    default: &ColumnProperties,
+) -> f64 {
+    column_or_default(
+        column,
+        default,
+        ColumnProperties::data_page_v2_compression_ratio_threshold,
+    )
+    .unwrap_or(DEFAULT_DATA_PAGE_V2_COMPRESSION_RATIO_THRESHOLD)
+}
+
+fn resolve_bloom_filter_properties<'a>(
+    column: Option<&'a ColumnProperties>,
+    default: &'a ColumnProperties,
+) -> Option<&'a BloomFilterProperties> {
+    column
+        .and_then(ColumnProperties::bloom_filter_properties)
+        .or_else(|| default.bloom_filter_properties())
+}
+
 /// Reference counted reader properties.
 pub type ReaderPropertiesPtr = Arc<ReaderProperties>;
 
@@ -1936,6 +2061,85 @@ mod tests {
         assert_eq!(WriterVersion::PARQUET_2_0.as_num(), 2);
     }
 
+    /// Every setting resolved in one pass must equal what the individual
+    /// per-column accessors return, for a column that overrides settings, a
+    /// column that inherits them, and settings left at their defaults.
+    #[test]
+    fn test_resolve_column_properties_matches_individual_accessors() {
+        let overridden = ColumnPath::from("overridden");
+        let inherited = ColumnPath::from("inherited");
+
+        let props = WriterProperties::builder()
+            .set_encoding(Encoding::DELTA_BINARY_PACKED)
+            .set_compression(Compression::SNAPPY)
+            .set_dictionary_enabled(false)
+            .set_statistics_enabled(EnabledStatistics::Chunk)
+            .set_write_page_header_statistics(false)
+            .set_data_page_size_limit(1111)
+            .set_dictionary_page_size_limit(2222)
+            .set_data_page_v2_compression_ratio_threshold(0.25)
+            .set_bloom_filter_enabled(true)
+            .set_column_encoding(overridden.clone(), Encoding::PLAIN)
+            .set_column_compression(overridden.clone(), 
Compression::UNCOMPRESSED)
+            .set_column_dictionary_enabled(overridden.clone(), true)
+            .set_column_statistics_enabled(overridden.clone(), 
EnabledStatistics::Page)
+            .set_column_write_page_header_statistics(overridden.clone(), true)
+            .set_column_data_page_size_limit(overridden.clone(), 3333)
+            .set_column_dictionary_page_size_limit(overridden.clone(), 4444)
+            
.set_column_data_page_v2_compression_ratio_threshold(overridden.clone(), 0.75)
+            .set_column_bloom_filter_fpp(overridden.clone(), 0.5)
+            .build();
+
+        // A column with no overrides at all, on properties that are themselves
+        // entirely default.
+        let bare = WriterProperties::builder().build();
+
+        for (props, col) in [
+            (&props, &overridden),
+            (&props, &inherited),
+            (&bare, &inherited),
+        ] {
+            let resolved = props.resolve_column_properties(col);
+            assert_eq!(resolved.encoding, props.encoding(col), "{col:?}");
+            assert_eq!(resolved.compression, props.compression(col), 
"{col:?}");
+            assert_eq!(
+                resolved.dictionary_enabled,
+                props.dictionary_enabled(col),
+                "{col:?}"
+            );
+            assert_eq!(
+                resolved.statistics_enabled,
+                props.statistics_enabled(col),
+                "{col:?}"
+            );
+            assert_eq!(
+                resolved.write_page_header_statistics,
+                props.write_page_header_statistics(col),
+                "{col:?}"
+            );
+            assert_eq!(
+                resolved.data_page_size_limit,
+                props.column_data_page_size_limit(col),
+                "{col:?}"
+            );
+            assert_eq!(
+                resolved.dictionary_page_size_limit,
+                props.column_dictionary_page_size_limit(col),
+                "{col:?}"
+            );
+            assert_eq!(
+                resolved.data_page_v2_compression_ratio_threshold,
+                props.column_data_page_v2_compression_ratio_threshold(col),
+                "{col:?}"
+            );
+            assert_eq!(
+                resolved.bloom_filter_properties.as_ref(),
+                props.bloom_filter_properties(col),
+                "{col:?}"
+            );
+        }
+    }
+
     #[test]
     fn test_writer_properties_default_settings() {
         let props = WriterProperties::default();

Reply via email to