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

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 849c7a3d fix(table): enable Parquet dictionary encoding by default 
(#931)
849c7a3d is described below

commit 849c7a3d969be39475467c1fa0404e851b47153a
Author: Tobias Pütz <[email protected]>
AuthorDate: Thu Aug 6 18:35:36 2026 +0200

    fix(table): enable Parquet dictionary encoding by default (#931)
    
    * fix(table): enable Parquet dictionary encoding by default
    
    * add requested test
    
    * fixes
    
    * test(parquet): use AppendRowGroupChecked in dictionary test helpers
    
    Replace deprecated file.ParquetWriter.AppendRowGroup with the checked
    variant to satisfy staticcheck SA1019, matching the rest of the file.
    
    Signed-off-by: Tobias Pütz <[email protected]>
    
    * address review
    
    ---------
    
    Signed-off-by: Tobias Pütz <[email protected]>
---
 table/internal/parquet_files.go      |  42 +++++-
 table/internal/parquet_files_test.go | 256 ++++++++++++++++++++++++++++++++++-
 website/src/configuration.md         |   4 +
 3 files changed, 294 insertions(+), 8 deletions(-)

diff --git a/table/internal/parquet_files.go b/table/internal/parquet_files.go
index dd301a75..56e9e939 100644
--- a/table/internal/parquet_files.go
+++ b/table/internal/parquet_files.go
@@ -70,6 +70,13 @@ const (
        ParquetBloomFilterMaxBytesDefault        = 1024 * 1024
        ParquetBloomFilterColumnEnabledKeyPrefix = 
"write.parquet.bloom-filter-enabled.column"
 
+       // Deliberately not namespaced under write.parquet: this is the 
parquet-mr key
+       // Iceberg Java reads straight from the table properties, so a table 
carrying
+       // it gets the same dictionary behavior from either implementation.
+       ParquetDictEnabledKey                     = "parquet.enable.dictionary"
+       ParquetDictEnabledDefault                 = true
+       ParquetDictEncodingColumnEnabledKeyPrefix = 
"write.parquet.dict-encoding-enabled.column"
+
        ParquetShredVariantsKey     = "write.parquet.shred-variants"
        ParquetShredVariantsDefault = false
        // Rows buffered per file to infer shredding (held per open partition 
writer).
@@ -260,7 +267,6 @@ func (parquetFormat) GetWriteProperties(props 
iceberg.Properties) any {
        }
 
        writerProps := []parquet.WriterProperty{
-               parquet.WithDictionaryDefault(false),
                
parquet.WithMaxRowGroupLength(int64(props.GetInt(ParquetRowGroupLimitKey,
                        ParquetRowGroupLimitDefault))),
                
parquet.WithDataPageSize(int64(props.GetInt(ParquetPageSizeBytesKey,
@@ -314,16 +320,40 @@ func (parquetFormat) GetWriteProperties(props 
iceberg.Properties) any {
                if !ok || colName == "" {
                        continue
                }
-               // EqualFold matches Java's Boolean.parseBoolean: only "true"
-               // (case-insensitive) is true; anything else, including "1" or
-               // "yes", is false. strconv.ParseBool behaves differently ("1" 
→ true).
-               enabled := strings.EqualFold(val, "true")
-               writerProps = append(writerProps, 
parquet.WithBloomFilterEnabledFor(colName, enabled))
+               writerProps = append(writerProps, 
parquet.WithBloomFilterEnabledFor(colName, javaBool(val)))
+       }
+
+       // Dictionary encoding is on unless parquet.enable.dictionary disables 
it;
+       // write.parquet.dict-encoding-enabled.column.<col-name> overrides per 
column.
+       dictEnabled := ParquetDictEnabledDefault
+       if val, ok := props[ParquetDictEnabledKey]; ok {
+               dictEnabled = javaBool(val)
+       }
+       writerProps = append(writerProps, 
parquet.WithDictionaryDefault(dictEnabled))
+
+       // The suffix is matched against the Parquet physical column path, not 
the
+       // Iceberg field name. These differ for nested fields (Iceberg 
`items.element`
+       // is `items.list.element` in Parquet), so a per-column override on one 
of
+       // those has no effect. Same for the bloom filter prefix above.
+       prefix = ParquetDictEncodingColumnEnabledKeyPrefix + "."
+       for key, val := range props {
+               colName, ok := strings.CutPrefix(key, prefix)
+               if !ok || colName == "" {
+                       continue
+               }
+               writerProps = append(writerProps, 
parquet.WithDictionaryFor(colName, javaBool(val)))
        }
 
        return writerProps
 }
 
+// javaBool matches Java's Boolean.parseBoolean, which Iceberg's Java writer 
uses
+// for these properties: only "true" (case-insensitive) is true; anything else,
+// including "1" and "yes", is false. strconv.ParseBool differs ("1" → true).
+func javaBool(val string) bool {
+       return strings.EqualFold(val, "true")
+}
+
 // ParquetRowGroupTargetSizeBytes returns the configured uncompressed row-group
 // size target. Iceberg Java rejects invalid and non-positive values rather 
than
 // silently falling back to the default.
diff --git a/table/internal/parquet_files_test.go 
b/table/internal/parquet_files_test.go
index a7deab5f..b1c8c97f 100644
--- a/table/internal/parquet_files_test.go
+++ b/table/internal/parquet_files_test.go
@@ -1247,12 +1247,264 @@ func TestGetWritePropertiesPageVersion(t *testing.T) {
                        pageRdr, err := rdr.RowGroup(0).GetColumnPageReader(0)
                        require.NoError(t, err)
 
-                       require.True(t, pageRdr.Next())
-                       assert.Equal(t, tt.expectedPageType, 
pageRdr.Page().Type())
+                       var dataPageType file.PageType
+                       var foundDataPage bool
+                       for pageRdr.Next() {
+                               pt := pageRdr.Page().Type()
+                               if pt == file.PageTypeDictionaryPage {
+                                       continue
+                               }
+                               dataPageType = pt
+                               foundDataPage = true
+
+                               break
+                       }
+                       require.True(t, foundDataPage, "expected a data page in 
row group")
+                       assert.Equal(t, tt.expectedPageType, dataPageType)
                })
        }
 }
 
+func TestGetWritePropertiesDictionaryEnabledByDefault(t *testing.T) {
+       format := internal.GetFileFormat(iceberg.ParquetFile)
+       writeProps := 
format.GetWriteProperties(iceberg.Properties{}).([]parquet.WriterProperty)
+
+       root, err := schema.NewGroupNode("schema", 
parquet.Repetitions.Required, schema.FieldList{
+               schema.NewInt32Node("col", parquet.Repetitions.Required, -1),
+       }, -1)
+       require.NoError(t, err)
+
+       var buf bytes.Buffer
+       pw := file.NewParquetWriter(&buf, root, file.WithWriterProps(
+               parquet.NewWriterProperties(writeProps...),
+       ))
+
+       rgw, err := pw.AppendRowGroupChecked()
+       require.NoError(t, err)
+       cw, err := rgw.NextColumn()
+       require.NoError(t, err)
+
+       // Low-cardinality column stays dictionary-encoded (well under the 
dict-size limit).
+       values := make([]int32, 1024)
+       for i := range values {
+               values[i] = int32(i % 8)
+       }
+       _, err = cw.(*file.Int32ColumnChunkWriter).WriteBatch(values, nil, nil)
+       require.NoError(t, err)
+       require.NoError(t, cw.Close())
+       require.NoError(t, rgw.Close())
+       require.NoError(t, pw.Close())
+
+       rdr, err := file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+       require.NoError(t, err)
+       defer rdr.Close()
+
+       colMeta, err := rdr.MetaData().RowGroup(0).ColumnChunk(0)
+       require.NoError(t, err)
+
+       assert.True(t, colMeta.HasDictionaryPage(),
+               "expected a dictionary page with default write properties")
+       assert.Contains(t, colMeta.Encodings(), parquet.Encodings.RLEDict,
+               "expected RLE_DICTIONARY encoding with default write 
properties")
+}
+
+func TestGetWritePropertiesDictionaryEncoding(t *testing.T) {
+       format := internal.GetFileFormat(iceberg.ParquetFile)
+       writerProps := func(props iceberg.Properties) *parquet.WriterProperties 
{
+               return 
parquet.NewWriterProperties(format.GetWriteProperties(props).([]parquet.WriterProperty)...)
+       }
+
+       t.Run("enabled by default", func(t *testing.T) {
+               wp := writerProps(iceberg.Properties{})
+               assert.True(t, wp.DictionaryEnabled())
+               assert.True(t, wp.DictionaryEnabledFor("any_col"))
+       })
+
+       t.Run("global opt-out", func(t *testing.T) {
+               wp := 
writerProps(iceberg.Properties{internal.ParquetDictEnabledKey: "false"})
+               assert.False(t, wp.DictionaryEnabled())
+               assert.False(t, wp.DictionaryEnabledFor("any_col"))
+       })
+
+       t.Run("per-column opt-out under the default", func(t *testing.T) {
+               wp := writerProps(iceberg.Properties{
+                       internal.ParquetDictEncodingColumnEnabledKeyPrefix + 
".name": "false",
+               })
+               assert.False(t, wp.DictionaryEnabledFor("name"))
+               assert.True(t, wp.DictionaryEnabledFor("id"), "unmentioned 
columns follow the global setting")
+       })
+
+       t.Run("per-column opt-in under a global opt-out", func(t *testing.T) {
+               wp := writerProps(iceberg.Properties{
+                       internal.ParquetDictEnabledKey:                         
      "false",
+                       internal.ParquetDictEncodingColumnEnabledKeyPrefix + 
".id":   "true",
+                       internal.ParquetDictEncodingColumnEnabledKeyPrefix + 
".name": "false",
+               })
+               assert.True(t, wp.DictionaryEnabledFor("id"))
+               assert.False(t, wp.DictionaryEnabledFor("name"))
+               assert.False(t, wp.DictionaryEnabledFor("unmentioned_col"), 
"unmentioned columns follow the global setting")
+       })
+
+       // Java reads both keys with Boolean.parseBoolean, so only "true" 
enables.
+       t.Run("java boolean semantics", func(t *testing.T) {
+               for _, val := range []string{"true", "TRUE", "True"} {
+                       assert.True(t, 
writerProps(iceberg.Properties{internal.ParquetDictEnabledKey: 
val}).DictionaryEnabled(), val)
+               }
+               for _, val := range []string{"false", "FALSE", "1", "yes", "", 
"bogus"} {
+                       assert.False(t, 
writerProps(iceberg.Properties{internal.ParquetDictEnabledKey: 
val}).DictionaryEnabled(), val)
+               }
+       })
+}
+
+func TestGetWritePropertiesDictionaryEncodingRoundTrip(t *testing.T) {
+       values := make([]parquet.ByteArray, 4096)
+       for i := range values {
+               values[i] = parquet.ByteArray(fmt.Sprintf("cat_%02d", i%8))
+       }
+
+       t.Run("default writes a dictionary", func(t *testing.T) {
+               chunk := writeDictTestColumn(t, iceberg.Properties{}, values)
+               assert.True(t, chunk.hasDictPage)
+               assert.Positive(t, chunk.dictDataPages)
+               assert.Zero(t, chunk.plainDataPages)
+       })
+
+       t.Run("global opt-out writes no dictionary", func(t *testing.T) {
+               chunk := writeDictTestColumn(t, iceberg.Properties{
+                       internal.ParquetDictEnabledKey: "false",
+               }, values)
+               assert.False(t, chunk.hasDictPage)
+               assert.Zero(t, chunk.dictDataPages)
+               assert.Positive(t, chunk.plainDataPages)
+       })
+
+       t.Run("per-column opt-out writes no dictionary", func(t *testing.T) {
+               chunk := writeDictTestColumn(t, iceberg.Properties{
+                       internal.ParquetDictEncodingColumnEnabledKeyPrefix + 
"." + dictTestColumn: "false",
+               }, values)
+               assert.False(t, chunk.hasDictPage)
+               assert.Zero(t, chunk.dictDataPages)
+               assert.Positive(t, chunk.plainDataPages)
+       })
+
+       t.Run("per-column opt-in under a global opt-out writes a dictionary", 
func(t *testing.T) {
+               chunk := writeDictTestColumn(t, iceberg.Properties{
+                       internal.ParquetDictEnabledKey:                         
                   "false",
+                       internal.ParquetDictEncodingColumnEnabledKeyPrefix + 
"." + dictTestColumn: "true",
+               }, values)
+               assert.True(t, chunk.hasDictPage)
+               assert.Positive(t, chunk.dictDataPages)
+               assert.Zero(t, chunk.plainDataPages)
+       })
+}
+
+// TestGetWritePropertiesDictionaryHighCardinalityFallback guards the risk that
+// kept dictionary encoding disabled until now: with the default write 
properties
+// a column too distinct to dictionary encode must fall back to PLAIN, strand 
no
+// dictionary page, and not grow the chunk against a dictionary-disabled write.
+func TestGetWritePropertiesDictionaryHighCardinalityFallback(t *testing.T) {
+       const valueWidth = 32
+
+       // Enough distinct values to overflow the default 2 MB dictionary 
budget.
+       values := make([]parquet.ByteArray, 100000)
+       for i := range values {
+               b := make([]byte, valueWidth)
+               for j := range b {
+                       b[j] = byte('A' + (i+j*31)%26)
+               }
+               tail := fmt.Appendf(nil, "-%07d", i)
+               copy(b[valueWidth-len(tail):], tail)
+               values[i] = parquet.ByteArray(b)
+       }
+
+       dictOn := writeDictTestColumn(t, iceberg.Properties{}, values)
+       dictOff := writeDictTestColumn(t, iceberg.Properties{
+               internal.ParquetDictEnabledKey: "false",
+       }, values)
+
+       t.Logf("dict=on compressed=%d hasDictPage=%v dictPages=%d 
plainPages=%d; dict=off compressed=%d",
+               dictOn.compressedSize, dictOn.hasDictPage, 
dictOn.dictDataPages, dictOn.plainDataPages,
+               dictOff.compressedSize)
+
+       assert.False(t, dictOn.hasDictPage, "overflowed dictionary must be 
discarded, not stranded in the chunk")
+       assert.Zero(t, dictOn.dictDataPages)
+       assert.Positive(t, dictOn.plainDataPages, "high-cardinality column must 
fall back to PLAIN")
+
+       // Discarding re-encodes the buffered values as PLAIN, so the chunk 
matches a
+       // dictionary-disabled write up to page-boundary rounding.
+       assert.LessOrEqual(t, dictOn.compressedSize, 
dictOff.compressedSize+dictOff.compressedSize/50,
+               "dictionary fallback must not grow the column chunk")
+}
+
+const dictTestColumn = "v"
+
+type dictChunkSummary struct {
+       compressedSize int64
+       hasDictPage    bool
+       dictDataPages  int
+       plainDataPages int
+}
+
+// writeDictTestColumn writes values as a single required BYTE_ARRAY column 
using
+// the write properties derived from tableProps, then summarizes the encodings 
of
+// the resulting column chunk.
+func writeDictTestColumn(t *testing.T, tableProps iceberg.Properties, values 
[]parquet.ByteArray) dictChunkSummary {
+       t.Helper()
+
+       format := internal.GetFileFormat(iceberg.ParquetFile)
+       writeProps := 
format.GetWriteProperties(tableProps).([]parquet.WriterProperty)
+
+       root, err := schema.NewGroupNode("schema", 
parquet.Repetitions.Required, schema.FieldList{
+               schema.NewByteArrayNode(dictTestColumn, 
parquet.Repetitions.Required, -1),
+       }, -1)
+       require.NoError(t, err)
+
+       var buf bytes.Buffer
+       pw := file.NewParquetWriter(&buf, root, file.WithWriterProps(
+               parquet.NewWriterProperties(writeProps...),
+       ))
+
+       rgw, err := pw.AppendRowGroupChecked()
+       require.NoError(t, err)
+       cw, err := rgw.NextColumn()
+       require.NoError(t, err)
+       _, err = cw.(*file.ByteArrayColumnChunkWriter).WriteBatch(values, nil, 
nil)
+       require.NoError(t, err)
+       require.NoError(t, cw.Close())
+       require.NoError(t, rgw.Close())
+       require.NoError(t, pw.Close())
+
+       rdr, err := file.NewParquetReader(bytes.NewReader(buf.Bytes()))
+       require.NoError(t, err)
+       defer rdr.Close()
+
+       colMeta, err := rdr.MetaData().RowGroup(0).ColumnChunk(0)
+       require.NoError(t, err)
+
+       summary := dictChunkSummary{
+               compressedSize: colMeta.TotalCompressedSize(),
+               hasDictPage:    colMeta.HasDictionaryPage(),
+       }
+
+       pageRdr, err := rdr.RowGroup(0).GetColumnPageReader(0)
+       require.NoError(t, err)
+       for pageRdr.Next() {
+               page := pageRdr.Page()
+               if page.Type() != file.PageTypeDataPage && page.Type() != 
file.PageTypeDataPageV2 {
+                       continue
+               }
+               switch parquet.Encoding(page.Encoding()) {
+               case parquet.Encodings.RLEDict, parquet.Encodings.PlainDict:
+                       summary.dictDataPages++
+               case parquet.Encodings.Plain:
+                       summary.plainDataPages++
+               }
+       }
+       require.NoError(t, pageRdr.Err())
+
+       return summary
+}
+
 func TestGetWritePropertiesBloomFilter(t *testing.T) {
        format := internal.GetFileFormat(iceberg.ParquetFile)
 
diff --git a/website/src/configuration.md b/website/src/configuration.md
index 827c24ca..277984cc 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -331,9 +331,13 @@ All defined in `table/internal/parquet_files.go` and read 
by the Parquet writer:
 | `write.parquet.compression-level` | `-1` (codec default) |
 | `write.parquet.bloom-filter-max-bytes` | 1 MB |
 | `write.parquet.bloom-filter-enabled.column.<name>` | (per-column toggle, 
prefix-matched) |
+| `parquet.enable.dictionary` | `true` (global dictionary encoding switch; 
same key as Iceberg Java) |
+| `write.parquet.dict-encoding-enabled.column.<name>` | (per-column toggle, 
prefix-matched; overrides the global switch) |
 | `write.parquet.shred-variants` | `false` (opt-in shredding of top-level 
variant columns) |
 | `write.parquet.variant-inference-buffer-size` | 100 rows (buffered per file 
to infer the shredding schema) |
 
+The bloom filter and dictionary toggles are parsed the way Iceberg Java parses 
them: only `true` (case-insensitive) enables, so any other value — including 
`1` — reads as `false`.
+
 ### Parquet reader
 
 | Key | Description |

Reply via email to