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 e7f50c3a2 perf(compaction): reduce split task normalization work
(#2377)
e7f50c3a2 is described below
commit e7f50c3a2d6fa9a4bdd8f03e85c91bc6e88c1787
Author: Minh Vu <[email protected]>
AuthorDate: Thu Nov 5 21:10:13 2026 +0100
perf(compaction): reduce split task normalization work (#2377)
* perf(compaction): reduce split task normalization work
Signed-off-by: Minh Vu <[email protected]>
* test(compaction): address split normalization review
Move the shared-delete-slice acceptance case alongside file-grain tests,
remove redundant test coverage, and clarify the slice shortcut comment.
Signed-off-by: Minh Vu <[email protected]>
---------
Signed-off-by: Minh Vu <[email protected]>
---
table/compaction/compaction.go | 36 +++++++--
table/compaction/compaction_file_grain_test.go | 18 +++++
.../compaction_normalization_perf_test.go | 85 ++++++++++++++++++++++
3 files changed, 133 insertions(+), 6 deletions(-)
diff --git a/table/compaction/compaction.go b/table/compaction/compaction.go
index 1af817f3c..7f4411620 100644
--- a/table/compaction/compaction.go
+++ b/table/compaction/compaction.go
@@ -242,20 +242,22 @@ func (cfg Config) PlanCompaction(tasks
[]table.FileScanTask) (Plan, error) {
// selecting, or packing them. Keep the first task's delete and lineage
// metadata while replacing its range with the complete file range.
wholeFileTasks := make([]table.FileScanTask, 0, len(tasks))
- seenFiles := make(map[string]table.FileScanTask, len(tasks))
+ // Store indexes rather than task copies: a FileScanTask contains
several
+ // interfaces and slices, so a map value copy is expensive at scale.
+ seenFiles := make(map[string]int, len(tasks))
for _, task := range tasks {
if task.File == nil {
return Plan{}, fmt.Errorf("%w: compaction task has no
data file", table.ErrInvalidOperation)
}
path := task.File.FilePath()
- if first, ok := seenFiles[path]; ok {
- if !sameCompactionMetadata(first, task) {
+ if index, ok := seenFiles[path]; ok {
+ if !sameCompactionMetadata(wholeFileTasks[index], task)
{
return Plan{}, fmt.Errorf("%w: split tasks for
%q have different delete or lineage metadata", table.ErrInvalidOperation, path)
}
continue
}
- seenFiles[path] = task
+ seenFiles[path] = len(wholeFileTasks)
task.Start = 0
task.Length = task.File.FileSizeBytes()
task.Residual = nil
@@ -344,15 +346,37 @@ func (cfg Config) PlanCompaction(tasks
[]table.FileScanTask) (Plan, error) {
return plan, nil
}
+// sameCompactionOptionalInt64 compares the values, not pointer identities:
+// independently decoded scan tasks may have distinct pointers.
+func sameCompactionOptionalInt64(a, b *int64) bool {
+ if a == nil || b == nil {
+ return a == b
+ }
+
+ return *a == *b
+}
+
func sameCompactionMetadata(a, b table.FileScanTask) bool {
equalDataFiles := func(a, b []iceberg.DataFile) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ // Scan planning commonly reuses immutable delete-file slices
across
+ // splits. The same backing array and length prove identical
elements,
+ // regardless of capacity, without a deep comparison.
+ if len(a) == 0 || &a[0] == &b[0] {
+ return true
+ }
+
+ // Keep deep value equality for independently decoded delete
lists.
+ // DataFile is an interface and may contain an uncomparable
value.
return slices.EqualFunc(a, b, func(a, b iceberg.DataFile) bool {
return reflect.DeepEqual(a, b)
})
}
- return reflect.DeepEqual(a.FirstRowID, b.FirstRowID) &&
- reflect.DeepEqual(a.DataSequenceNumber, b.DataSequenceNumber) &&
+ return sameCompactionOptionalInt64(a.FirstRowID, b.FirstRowID) &&
+ sameCompactionOptionalInt64(a.DataSequenceNumber,
b.DataSequenceNumber) &&
equalDataFiles(a.DeleteFiles, b.DeleteFiles) &&
equalDataFiles(a.EqualityDeleteFiles, b.EqualityDeleteFiles) &&
equalDataFiles(a.DeletionVectorFiles, b.DeletionVectorFiles)
diff --git a/table/compaction/compaction_file_grain_test.go
b/table/compaction/compaction_file_grain_test.go
index 40a57193c..ff335646c 100644
--- a/table/compaction/compaction_file_grain_test.go
+++ b/table/compaction/compaction_file_grain_test.go
@@ -74,6 +74,24 @@ func TestPlanCompactionCountsWholeFiles(t *testing.T) {
require.Equal(t, original, tasks, "normalization must not mutate caller
tasks")
}
+func TestPlanCompactionAcceptsSharedSplitDeleteFiles(t *testing.T) {
+ file := newDataFile("shared.parquet", 10)
+ first := makeTask(file, 0, 0)
+ first.DeleteFiles = []iceberg.DataFile{newDeleteFile("a.pos"),
newDeleteFile("b.pos")}
+ first.Length = file.size / 2
+ second := first
+ second.Start, second.Length = first.Length, file.size-first.Length
+
+ cfg := compaction.DefaultConfig()
+ cfg.MinInputFiles = 1
+ plan, err := cfg.PlanCompaction([]table.FileScanTask{first, second})
+ require.NoError(t, err)
+ require.Equal(t, 1, plan.TotalInputFiles)
+ require.Len(t, plan.Groups, 1)
+ require.Len(t, plan.Groups[0].Tasks, 1)
+ require.Equal(t, 2, plan.Groups[0].DeleteFileCount)
+}
+
func TestPlanCompactionRejectsConflictingSplitMetadata(t *testing.T) {
for _, tc := range []struct {
name string
diff --git a/table/compaction/compaction_normalization_perf_test.go
b/table/compaction/compaction_normalization_perf_test.go
new file mode 100644
index 000000000..6d302e05d
--- /dev/null
+++ b/table/compaction/compaction_normalization_perf_test.go
@@ -0,0 +1,85 @@
+// 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 compaction_test
+
+import (
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/table"
+ "github.com/apache/iceberg-go/table/compaction"
+)
+
+// BenchmarkPlanCompactionSplitNormalization measures the full planner with
+// right-sized files. It excludes partition packing by configuring all files
+// as non-candidates, so the workload focuses on split normalization and
+// metadata consistency checks. Input construction is outside the timer.
+func BenchmarkPlanCompactionSplitNormalization(b *testing.B) {
+ for _, tc := range []struct {
+ files int
+ splits int
+ deletes int
+ copiedDeletes bool
+ }{
+ {files: 20_000, splits: 4},
+ {files: 5_000, splits: 8, deletes: 8},
+ {files: 3_000, splits: 8, deletes: 64},
+ {files: 5_000, splits: 8, deletes: 16, copiedDeletes: true},
+ } {
+ label := fmt.Sprintf("files=%d/splits=%d/deletes=%d/copied=%t",
tc.files, tc.splits, tc.deletes, tc.copiedDeletes)
+ b.Run(label, func(b *testing.B) {
+ tasks := make([]table.FileScanTask, 0,
tc.files*tc.splits)
+ for i := range tc.files {
+ file :=
newDataFile(fmt.Sprintf("file-%d.parquet", i), 500)
+ deletes := make([]iceberg.DataFile, tc.deletes)
+ for j := range deletes {
+ deletes[j] =
newDeleteFile(fmt.Sprintf("file-%d-delete-%d.pos", i, j))
+ }
+ original := table.FileScanTask{File: file,
Length: file.size, DeleteFiles: deletes}
+ for split := range tc.splits {
+ task := original
+ task.Start = int64(split) * file.size /
int64(tc.splits)
+ task.Length = file.size /
int64(tc.splits)
+ if split == tc.splits-1 {
+ task.Length = file.size -
task.Start
+ }
+ if tc.copiedDeletes {
+ task.DeleteFiles =
slices.Clone(deletes)
+ }
+ tasks = append(tasks, task)
+ }
+ }
+
+ cfg := compaction.DefaultConfig()
+ cfg.DeleteFileThreshold = 1_000
+ cfg.DeleteRatioThreshold = 0
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ plan, err := cfg.PlanCompaction(tasks)
+ if err != nil {
+ b.Fatal(err)
+ }
+ compactionPlanBenchmarkSink = plan
+ }
+ })
+ }
+}