This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new dd230dbc [parquet] Support per-column dictionary encoding options
(#1034)
dd230dbc is described below
commit dd230dbcac6fd50a1754d54fed2f49de5d68a50f
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Oct 7 10:12:33 2026 +0800
[parquet] Support per-column dictionary encoding options (#1034)
---
crates/paimon/src/arrow/format/parquet.rs | 196 +++++++++++++++++++++++++++---
docs/src/getting-started.md | 8 ++
2 files changed, 190 insertions(+), 14 deletions(-)
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 44efbc8c..5f7a9eb8 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -369,11 +369,11 @@ impl ParquetFormatWriter {
let codec = parse_compression(compression, zstd_level)?;
let core_options = CoreOptions::new(format_options);
let page_index_enabled =
core_options.parquet_write_page_index_enabled()?;
+ let props = parquet_writer_properties(codec, page_index_enabled,
format_options)?;
let async_write = output.async_writer().await?;
let input_schema = schema;
let schema = timestamp_millis_schema(&input_schema);
- let inner =
- create_parquet_arrow_writer(async_write, schema.clone(), codec,
page_index_enabled)?;
+ let inner = create_parquet_arrow_writer(async_write, schema.clone(),
props)?;
let stats_modes = write_fields
.map(|fields|
core_options.metadata_stats_modes(fields.iter().map(DataField::name)))
.transpose()?;
@@ -391,18 +391,8 @@ impl ParquetFormatWriter {
fn create_parquet_arrow_writer(
async_write: Box<dyn crate::io::AsyncFileWrite>,
schema: arrow_schema::SchemaRef,
- codec: Compression,
- page_index_enabled: bool,
+ props: WriterProperties,
) -> crate::Result<AsyncArrowWriter<Box<dyn crate::io::AsyncFileWrite>>> {
- let props = WriterProperties::builder()
- .set_compression(codec)
- .set_statistics_enabled(if page_index_enabled {
- EnabledStatistics::Page
- } else {
- EnabledStatistics::Chunk
- })
- .set_offset_index_disabled(!page_index_enabled)
- .build();
// Commit one final Arrow schema at close, after shredding metadata is
known.
let options = parquet::arrow::arrow_writer::ArrowWriterOptions::new()
.with_properties(props)
@@ -415,6 +405,49 @@ fn create_parquet_arrow_writer(
})
}
+fn parquet_writer_properties(
+ codec: Compression,
+ page_index_enabled: bool,
+ options: &HashMap<String, String>,
+) -> crate::Result<WriterProperties> {
+ const DICTIONARY: &str = "parquet.enable.dictionary";
+ const COLUMN_PREFIX: &str = "parquet.enable.dictionary#";
+ let mut props = WriterProperties::builder()
+ .set_compression(codec)
+ .set_statistics_enabled(if page_index_enabled {
+ EnabledStatistics::Page
+ } else {
+ EnabledStatistics::Chunk
+ })
+ .set_offset_index_disabled(!page_index_enabled);
+ let parse = |key: &str, value: &str| match
value.trim().to_ascii_lowercase().as_str() {
+ "true" => Ok(true),
+ "false" => Ok(false),
+ _ => Err(crate::Error::ConfigInvalid {
+ message: format!("Option '{key}' must be true or false, got:
{value}"),
+ }),
+ };
+ if let Some(value) = options.get(DICTIONARY) {
+ props = props.set_dictionary_enabled(parse(DICTIONARY, value)?);
+ }
+ for (key, value) in options {
+ if let Some(path) = key.strip_prefix(COLUMN_PREFIX) {
+ if path.split('.').any(str::is_empty) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!("Option '{key}' requires a nonempty
Parquet column path"),
+ });
+ }
+ props = props.set_column_dictionary_enabled(
+ parquet::schema::types::ColumnPath::new(
+ path.split('.').map(str::to_owned).collect(),
+ ),
+ parse(key, value)?,
+ );
+ }
+ }
+ Ok(props.build())
+}
+
/// Every `file.compression` value a parquet write accepts.
///
/// `brotli` and `lzo` are left out on purpose, although parquet names both
codecs:
@@ -2945,7 +2978,7 @@ fn split_ranges_for_concurrency(merged: Vec<Range<u64>>,
concurrency: usize) ->
#[cfg(test)]
#[allow(clippy::type_complexity)] // test row literals use nested
Option<Vec<(&str, Option<i64>)>>
mod tests {
- use super::build_parquet_row_filter;
+ use super::{build_parquet_row_filter, parquet_writer_properties};
use super::{
forward_row_group_batches, parquet_granules, parse_compression,
supported_compressions,
FilePredicates, ParquetFormatReader, ParquetFormatWriter,
ParquetRowGroupMessage,
@@ -4088,6 +4121,141 @@ mod tests {
assert_eq!(total_rows, 9);
}
+ #[tokio::test]
+ async fn test_parquet_writer_dictionary_options() {
+ use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+ use parquet::schema::types::ColumnPath;
+
+ for global in [true, false] {
+ let options = HashMap::from([
+ ("parquet.enable.dictionary".to_owned(), global.to_string()),
+ (
+ "parquet.enable.dictionary#value".to_owned(),
+ (!global).to_string(),
+ ),
+ (
+ "parquet.enable.dictionary#nested.value".to_owned(),
+ "false".to_owned(),
+ ),
+ ]);
+ let props =
+ parquet_writer_properties(Compression::UNCOMPRESSED, true,
&options).unwrap();
+ assert_eq!(props.dictionary_enabled(&ColumnPath::from("id")),
global);
+ assert_eq!(
+ props.dictionary_enabled(&ColumnPath::from("value")),
+ !global
+ );
+ assert!(
+
!props.dictionary_enabled(&ColumnPath::new(vec!["nested".into(),
"value".into()]))
+ );
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/dictionary_options.parquet";
+ let schema = writer_arrow_schema();
+ let batch = writer_test_batch(&schema, vec![1, 1, 1], vec![10, 10,
10]);
+ let mut writer = ParquetFormatWriter::new(
+ &file_io.new_output(path).unwrap(),
+ schema,
+ "zstd",
+ 1,
+ None,
+ &options,
+ )
+ .await
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ Box::new(writer).close().await.unwrap();
+ let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
+ let reader =
ParquetRecordBatchReaderBuilder::try_new(bytes).unwrap();
+ let columns = reader.metadata().row_group(0).columns();
+ assert_eq!(columns[0].dictionary_page_offset().is_some(), global);
+ assert_eq!(columns[1].dictionary_page_offset().is_some(), !global);
+ let actual = reader.build().unwrap().next().unwrap().unwrap();
+ assert_eq!(actual, batch);
+ }
+ let props =
+ parquet_writer_properties(Compression::UNCOMPRESSED, true,
&HashMap::new()).unwrap();
+ assert!(props.dictionary_enabled(&ColumnPath::from("id")));
+ }
+
+ #[tokio::test]
+ async fn test_parquet_writer_nested_dictionary_option() {
+ use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+
+ let child = Arc::new(ArrowField::new("value", ArrowDataType::Int32,
false));
+ let fields: arrow_schema::Fields = vec![child].into();
+ let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+ "nested",
+ ArrowDataType::Struct(fields.clone()),
+ false,
+ )]));
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![Arc::new(StructArray::new(
+ fields,
+ vec![Arc::new(Int32Array::from(vec![7, 7, 7]))],
+ None,
+ ))],
+ )
+ .unwrap();
+ for enabled in [true, false] {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/nested_dictionary.parquet";
+ let options = HashMap::from([(
+ "parquet.enable.dictionary#nested.value".to_owned(),
+ enabled.to_string(),
+ )]);
+ let mut writer = ParquetFormatWriter::new(
+ &file_io.new_output(path).unwrap(),
+ schema.clone(),
+ "zstd",
+ 1,
+ None,
+ &options,
+ )
+ .await
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ Box::new(writer).close().await.unwrap();
+ let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
+ let reader =
ParquetRecordBatchReaderBuilder::try_new(bytes).unwrap();
+ assert_eq!(
+ reader
+ .metadata()
+ .row_group(0)
+ .column(0)
+ .dictionary_page_offset()
+ .is_some(),
+ enabled
+ );
+ assert_eq!(reader.build().unwrap().next().unwrap().unwrap(),
batch);
+ }
+ }
+
+ #[tokio::test]
+ async fn test_parquet_writer_invalid_dictionary_options_create_no_file() {
+ for (key, value) in [
+ ("parquet.enable.dictionary", "invalid"),
+ ("parquet.enable.dictionary#value", "invalid"),
+ ("parquet.enable.dictionary#", "false"),
+ ("parquet.enable.dictionary#a..b", "false"),
+ ] {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/invalid_dictionary.parquet";
+ let options = HashMap::from([(key.to_owned(), value.to_owned())]);
+ let result = ParquetFormatWriter::new(
+ &file_io.new_output(path).unwrap(),
+ writer_arrow_schema(),
+ "zstd",
+ 1,
+ None,
+ &options,
+ )
+ .await;
+ assert!(matches!(result, Err(crate::Error::ConfigInvalid { .. })));
+ assert!(!file_io.exists(path).await.unwrap());
+ }
+ }
+
#[tokio::test]
async fn test_parquet_writer_write_and_close() {
let file_io = FileIOBuilder::new("memory").build().unwrap();
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 8252cbd8..8a417b2b 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -124,6 +124,14 @@ cross-backend rename is rejected.
with `with_provider` on the same builder. Without a provider, property-based
storage configuration behaves as before.
+## Parquet Dictionary Encoding
+
+Dictionary encoding is enabled by default. Disable it for all columns with
+`parquet.enable.dictionary=false`, or override one column with
+`parquet.enable.dictionary#event_time=false` (Java-compatible syntax).
+Paths use dot-separated physical Parquet leaves, not logical MAP keys.
+These options affect new writes only.
+
## Parquet Page Pruning
Parquet Page Index pruning is enabled by default. Set