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

laskoviymishka 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 7536627ab perf(table): reuse partition path formatting plan (#1958)
7536627ab is described below

commit 7536627ab9a538b64bd0f86d7a9bdf5ec74180b1
Author: Minh Vu <[email protected]>
AuthorDate: Fri Sep 25 10:59:30 2026 +0200

    perf(table): reuse partition path formatting plan (#1958)
    
    Signed-off-by: Minh Vu <[email protected]>
---
 table/clustered_writer.go                |  4 +-
 table/partition_extraction_bench_test.go | 44 +++++++++++++++++
 table/partition_path.go                  | 60 +++++++++++++++++++++++
 table/partition_path_test.go             | 81 ++++++++++++++++++++++++++++++++
 table/partitioned_fanout_writer.go       | 12 ++++-
 5 files changed, 198 insertions(+), 3 deletions(-)

diff --git a/table/clustered_writer.go b/table/clustered_writer.go
index 9f5b7e97d..b14f6689c 100644
--- a/table/clustered_writer.go
+++ b/table/clustered_writer.go
@@ -119,12 +119,12 @@ func clusteredPartitionedWrite(
                                        return err
                                }
                                if 
completedPartitions.contains(part.partitionRec) {
-                                       partitionPath := 
spec.PartitionToPath(part.partitionRec, schema)
+                                       partitionPath := 
extractionPlan.pathPlan.format(part.partitionRec)
 
                                        return fmt.Errorf("clustered write: 
incoming records violate the clustering assumption; "+
                                                "partition %q has records 
arriving after its writer was already closed", partitionPath)
                                }
-                               partitionPath := 
spec.PartitionToPath(part.partitionRec, schema)
+                               partitionPath := 
extractionPlan.pathPlan.format(part.partitionRec)
                                currentWriter = factory.newRollingDataWriter(
                                        ctx, partitionPath, 
part.partitionValues, outputCh)
                                currentRec = part.partitionRec
diff --git a/table/partition_extraction_bench_test.go 
b/table/partition_extraction_bench_test.go
index d88bd78fd..ff0f02a6c 100644
--- a/table/partition_extraction_bench_test.go
+++ b/table/partition_extraction_bench_test.go
@@ -258,3 +258,47 @@ func BenchmarkPartitionTransforms(b *testing.B) {
                })
        }
 }
+
+func BenchmarkPartitionPathFormatting(b *testing.B) {
+       for _, fieldCount := range []int{1, 4, 16, 64} {
+               b.Run(fmt.Sprintf("fields_%d", fieldCount), func(b *testing.B) {
+                       arrowFields := make([]arrow.Field, fieldCount)
+                       icebergFields := make([]iceberg.NestedField, fieldCount)
+                       specFields := make([]iceberg.PartitionField, fieldCount)
+                       values := make(partitionRecord, fieldCount)
+                       for i := range fieldCount {
+                               name := fmt.Sprintf("part #%d", i)
+                               arrowFields[i] = arrow.Field{Name: name, Type: 
arrow.BinaryTypes.String}
+                               icebergFields[i] = iceberg.NestedField{ID: i + 
1, Name: name, Type: iceberg.PrimitiveTypes.String}
+                               specFields[i] = iceberg.PartitionField{
+                                       SourceIDs: []int{i + 1}, FieldID: 1000 
+ i,
+                                       Transform: iceberg.IdentityTransform{}, 
Name: name,
+                               }
+                               values[i] = fmt.Sprintf("value/%d", i)
+                       }
+
+                       schema := iceberg.NewSchema(0, icebergFields...)
+                       spec := iceberg.NewPartitionSpec(specFields...)
+                       arrowSchema := arrow.NewSchema(arrowFields, nil)
+                       plan, err := newPartitionExtractionPlan(spec, schema, 
arrowSchema)
+                       if err != nil {
+                               b.Fatal(err)
+                       }
+
+                       b.Run("partition_to_path", func(b *testing.B) {
+                               b.ReportAllocs()
+                               b.ResetTimer()
+                               for b.Loop() {
+                                       _ = spec.PartitionToPath(values, schema)
+                               }
+                       })
+                       b.Run("reused_plan", func(b *testing.B) {
+                               b.ReportAllocs()
+                               b.ResetTimer()
+                               for b.Loop() {
+                                       _ = plan.pathPlan.format(values)
+                               }
+                       })
+               })
+       }
+}
diff --git a/table/partition_path.go b/table/partition_path.go
new file mode 100644
index 000000000..5c360d1e2
--- /dev/null
+++ b/table/partition_path.go
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "net/url"
+       "strings"
+
+       "github.com/apache/iceberg-go"
+)
+
+type partitionPathField struct {
+       escapedName string
+       transform   iceberg.Transform
+       resultType  iceberg.Type
+}
+
+// partitionPathPlan contains the spec and schema state needed to format a
+// partition path. The values are immutable after construction, so a plan can
+// be shared by all workers handling the same table write.
+type partitionPathPlan struct {
+       fields        []partitionPathField
+       estimatedSize int
+}
+
+func (p partitionPathPlan) format(data iceberg.StructLike) string {
+       if len(p.fields) == 0 {
+               return ""
+       }
+
+       var sb strings.Builder
+       sb.Grow(p.estimatedSize)
+       for i, field := range p.fields {
+               if i > 0 {
+                       sb.WriteByte('/')
+               }
+
+               sb.WriteString(field.escapedName)
+               sb.WriteByte('=')
+               value := field.transform.ToHumanStrType(field.resultType, 
data.Get(i))
+               sb.WriteString(url.QueryEscape(value))
+       }
+
+       return sb.String()
+}
diff --git a/table/partition_path_test.go b/table/partition_path_test.go
new file mode 100644
index 000000000..358cecd36
--- /dev/null
+++ b/table/partition_path_test.go
@@ -0,0 +1,81 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package table
+
+import (
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestPartitionPathPlanMatchesPartitionToPath(t *testing.T) {
+       schema := iceberg.NewSchema(0,
+               iceberg.NestedField{ID: 1, Name: "created_ts_tz", Type: 
iceberg.PrimitiveTypes.TimestampTz, Required: true},
+               iceberg.NestedField{ID: 2, Name: "str", Type: 
iceberg.PrimitiveTypes.String},
+               iceberg.NestedField{ID: 3, Name: "id", Type: 
iceberg.PrimitiveTypes.Int32, Required: true},
+       )
+
+       unknown, err := iceberg.ParseTransform("custom_transform[42]")
+       require.NoError(t, err)
+       spec := iceberg.NewPartitionSpec(
+               iceberg.PartitionField{
+                       SourceIDs: []int{1}, FieldID: 1000,
+                       Transform: iceberg.IdentityTransform{}, Name: "created 
ts/tz",
+               },
+               iceberg.PartitionField{
+                       SourceIDs: []int{2}, FieldID: 1001,
+                       Transform: iceberg.TruncateTransform{Width: 8}, Name: 
"str#part",
+               },
+               iceberg.PartitionField{
+                       SourceIDs: []int{3}, FieldID: 1002,
+                       Transform: iceberg.BucketTransform{NumBuckets: 16}, 
Name: "id_bucket",
+               },
+               iceberg.PartitionField{
+                       SourceIDs: []int{4}, FieldID: 1003,
+                       Transform: iceberg.IdentityTransform{}, Name: "missing",
+               },
+               iceberg.PartitionField{
+                       SourceIDs: []int{3}, FieldID: 1004,
+                       Transform: unknown, Name: "unknown",
+               },
+       )
+
+       recordSchema := arrow.NewSchema([]arrow.Field{
+               {Name: "created_ts_tz", Type: 
arrow.FixedWidthTypes.Timestamp_us, Nullable: false},
+               {Name: "str", Type: arrow.BinaryTypes.String},
+               {Name: "id", Type: arrow.PrimitiveTypes.Int32, Nullable: false},
+       }, nil)
+       plan, err := newPartitionExtractionPlan(spec, schema, recordSchema)
+       require.NoError(t, err)
+
+       record := partitionRecord{
+               iceberg.Timestamp(1705314600000000),
+               "partition/value",
+               int32(7),
+               nil,
+               int32(42),
+       }
+
+       assert.Equal(t, spec.PartitionToPath(record, schema), 
plan.pathPlan.format(record))
+       assert.Equal(t,
+               
"created+ts%2Ftz=2024-01-15T10%3A30%3A00%2B00%3A00/str%23part=partition%2Fvalue/id_bucket=7/missing=null/unknown=42",
+               plan.pathPlan.format(record))
+}
diff --git a/table/partitioned_fanout_writer.go 
b/table/partitioned_fanout_writer.go
index b70d144cd..50c5f7f9a 100644
--- a/table/partitioned_fanout_writer.go
+++ b/table/partitioned_fanout_writer.go
@@ -76,6 +76,7 @@ type partitionExtractionPlan struct {
        schema       *iceberg.Schema
        recordSchema *arrow.Schema
        fields       []partitionFieldInfo
+       pathPlan     partitionPathPlan
 }
 
 type (
@@ -155,7 +156,7 @@ func newPartitionedFanoutWriter(partitionSpec 
iceberg.PartitionSpec, schema *ice
 }
 
 func (p *partitionedFanoutWriter) partitionPath(data partitionRecord) string {
-       return p.partitionSpec.PartitionToPath(data, p.schema)
+       return p.plan.pathPlan.format(data)
 }
 
 // Write writes the Arrow records to the specified location using a fanout 
pattern with
@@ -356,6 +357,7 @@ func getRecordPartitions(spec iceberg.PartitionSpec, schema 
*iceberg.Schema, rec
 func newPartitionExtractionPlan(spec iceberg.PartitionSpec, schema 
*iceberg.Schema, recordSchema *arrow.Schema) (*partitionExtractionPlan, error) {
        partitionFields := spec.PartitionType(schema).FieldList
        partitionFieldsInfo := make([]partitionFieldInfo, len(partitionFields))
+       pathPlan := partitionPathPlan{fields: make([]partitionPathField, 
len(partitionFields))}
        specFieldsByID := make(map[int]iceberg.PartitionField, spec.NumFields())
        for _, field := range spec.Fields() {
                specFieldsByID[field.FieldID] = field
@@ -366,6 +368,13 @@ func newPartitionExtractionPlan(spec 
iceberg.PartitionSpec, schema *iceberg.Sche
                if !ok {
                        return nil, fmt.Errorf("failed to find partition field 
ID %d in spec", partitionField.ID)
                }
+               pathField := partitionPathField{
+                       escapedName: sourceField.EscapedName(),
+                       transform:   sourceField.Transform,
+                       resultType:  partitionField.Type,
+               }
+               pathPlan.fields[i] = pathField
+               pathPlan.estimatedSize += len(pathField.escapedName) + 20
                partitionFieldsInfo[i].fieldID = partitionField.ID
                colName, ok := schema.FindColumnName(sourceField.SourceID())
                if !ok {
@@ -394,6 +403,7 @@ func newPartitionExtractionPlan(spec iceberg.PartitionSpec, 
schema *iceberg.Sche
                schema:       schema,
                recordSchema: recordSchema,
                fields:       partitionFieldsInfo,
+               pathPlan:     pathPlan,
        }, nil
 }
 

Reply via email to