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 774b4be6d perf(table): simplify equality delete conflict filters
(#2380)
774b4be6d is described below
commit 774b4be6da83c730500995614fc7e1e47c9ba558
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 18:55:39 2026 +0100
perf(table): simplify equality delete conflict filters (#2380)
* perf(table): simplify equality delete conflict filters
Signed-off-by: Minh Vu <[email protected]>
* refactor(table): address equality delete filter review
Restore conflict-filter rationale and reuse source field types.
Remove arbitrary grouping threshold and consolidate test coverage.
Signed-off-by: Minh Vu <[email protected]>
* perf(table): group high-precision decimal equality delete partitions
Compare decimal scale against the source field instead of literal precision.
Avoid unused group maps and add cast and decimal regression coverage.
Signed-off-by: Minh Vu <[email protected]>
* style(table): fix nlreturn in equality delete literal guard
---------
Signed-off-by: Minh Vu <[email protected]>
---
table/conflict_validation.go | 124 +++++++++++++++++++---
table/partition_conflict_bench_test.go | 27 +++++
table/partition_conflict_test.go | 188 ++++++++++++++++++++++++++++++++-
3 files changed, 320 insertions(+), 19 deletions(-)
diff --git a/table/conflict_validation.go b/table/conflict_validation.go
index 9d0a4ca33..2343a3f7b 100644
--- a/table/conflict_validation.go
+++ b/table/conflict_validation.go
@@ -53,6 +53,7 @@ import (
"errors"
"fmt"
"io/fs"
+ "math"
"slices"
"sort"
"strconv"
@@ -872,8 +873,66 @@ type equalityDeletePartitionSpecInfo struct {
}
type equalityDeleteSourceFieldInfo struct {
- name string
- found bool
+ name string
+ fieldType iceberg.Type
+ found bool
+}
+
+// NaNs are not equal to themselves as Go map keys, while the existing
+// equality evaluator compares two NaNs as equal. Binary sets use hash keys,
+// which can collide, so retain individual predicates for those values too.
+// Keep literals that require a cast on the equality path.
+func equalityDeleteLiteralCanUseSet(lit iceberg.Literal, fieldType
iceberg.Type) bool {
+ switch value := lit.(type) {
+ case iceberg.Float32Literal:
+ return !math.IsNaN(float64(value)) &&
lit.Type().Equals(fieldType)
+ case iceberg.Float64Literal:
+ return !math.IsNaN(float64(value)) &&
lit.Type().Equals(fieldType)
+ case iceberg.DecimalLiteral:
+ // DecimalLiteral.Type reports a placeholder precision of 9.
+ // Equal scales bind without conversion for any declared
precision.
+ decimalType, ok := fieldType.(iceberg.DecimalType)
+
+ return ok && value.Scale == decimalType.Scale()
+ case iceberg.BoolLiteral, iceberg.Int32Literal, iceberg.Int64Literal,
+ iceberg.DateLiteral, iceberg.TimeLiteral,
iceberg.TimestampLiteral,
+ iceberg.TimestampNsLiteral, iceberg.StringLiteral,
iceberg.UUIDLiteral:
+ return lit.Type().Equals(fieldType)
+ default:
+ return false
+ }
+}
+
+type equalityDeleteIdentityGroupKey struct {
+ specID int
+ fieldID int
+}
+
+type equalityDeleteIdentityGroup struct {
+ fieldName string
+ index int
+ literals []iceberg.Literal
+}
+
+// balancedConflictDisjunction combines partition terms pairwise so the OR
+// expression tree has logarithmic depth while preserving term order.
+func balancedConflictDisjunction(terms []iceberg.BooleanExpression)
iceberg.BooleanExpression {
+ if len(terms) == 0 {
+ return iceberg.AlwaysTrue{}
+ }
+ for len(terms) > 1 {
+ next := terms[:0]
+ for i := 0; i < len(terms); i += 2 {
+ if i+1 == len(terms) {
+ next = append(next, terms[i])
+ } else {
+ next = append(next, iceberg.NewOr(terms[i],
terms[i+1]))
+ }
+ }
+ terms = next
+ }
+
+ return terms[0]
}
// eqDeletePartitionsToFilter converts equality-delete data files into an
@@ -896,6 +955,8 @@ type equalityDeleteSourceFieldInfo struct {
func eqDeletePartitionsToFilter(files []iceberg.DataFile, meta Metadata)
(iceberg.BooleanExpression, error) {
terms := make([]iceberg.BooleanExpression, 0, len(files))
specInfoByID := make(map[int]*equalityDeletePartitionSpecInfo)
+ var identityGroups
map[equalityDeleteIdentityGroupKey]*equalityDeleteIdentityGroup
+ var groups []*equalityDeleteIdentityGroup
seen := make(map[string]struct{}, len(files))
var currentSchema *iceberg.Schema
@@ -962,6 +1023,7 @@ func eqDeletePartitionsToFilter(files []iceberg.DataFile,
meta Metadata) (iceber
}
conjuncts := make([]iceberg.BooleanExpression, 0, len(p))
+ grouped := false
for _, partFieldID := range fieldIDs {
pf, ok := specInfo.fields[partFieldID]
if !ok {
@@ -976,7 +1038,7 @@ func eqDeletePartitionsToFilter(files []iceberg.DataFile,
meta Metadata) (iceber
currentSchema = meta.CurrentSchema()
}
field, found :=
currentSchema.FindFieldByID(pf.FirstSourceID())
- sourceField =
equalityDeleteSourceFieldInfo{name: field.Name, found: found}
+ sourceField =
equalityDeleteSourceFieldInfo{name: field.Name, fieldType: field.Type, found:
found}
specInfo.sourceFields[partFieldID] = sourceField
}
if !sourceField.found {
@@ -995,25 +1057,57 @@ func eqDeletePartitionsToFilter(files
[]iceberg.DataFile, meta Metadata) (iceber
return nil, fmt.Errorf("partition field %q:
%w", sourceField.name, err)
}
- conjuncts = append(conjuncts,
iceberg.LiteralPredicate(iceberg.OpEQ, iceberg.Reference(sourceField.name),
lit))
+ // Keep values needing a cast on the equality path,
including
+ // strings that can cast to NaN floats during binding.
+ if len(fieldIDs) == 1 &&
equalityDeleteLiteralCanUseSet(lit, sourceField.fieldType) {
+ key := equalityDeleteIdentityGroupKey{specID:
specID, fieldID: partFieldID}
+ group := identityGroups[key]
+ if group == nil {
+ if identityGroups == nil {
+ identityGroups =
make(map[equalityDeleteIdentityGroupKey]*equalityDeleteIdentityGroup)
+ }
+ group =
&equalityDeleteIdentityGroup{fieldName: sourceField.name, index: len(terms)}
+ identityGroups[key] = group
+ groups = append(groups, group)
+ terms = append(terms, nil)
+ }
+ group.literals = append(group.literals, lit)
+ grouped = true
+
+ break
+ }
+
+ conjuncts = append(conjuncts,
+ iceberg.LiteralPredicate(iceberg.OpEQ,
iceberg.Reference(sourceField.name), lit))
+ }
+ if grouped {
+ continue
}
+ var term iceberg.BooleanExpression
if len(conjuncts) == 1 {
- terms = append(terms, conjuncts[0])
+ term = conjuncts[0]
} else {
- terms = append(terms, iceberg.NewAnd(conjuncts[0],
conjuncts[1], conjuncts[2:]...))
+ term = iceberg.NewAnd(conjuncts[0], conjuncts[1],
conjuncts[2:]...)
+ }
+ terms = append(terms, term)
+ }
+
+ for _, group := range groups {
+ // SetPredicate keeps singleton groups as ordinary equality
predicates.
+ // Keep larger IN sets within the metrics evaluator's per-value
limit.
+ for start := 0; start < len(group.literals); start +=
inPredicateLimit {
+ end := min(start+inPredicateLimit, len(group.literals))
+ term := iceberg.SetPredicate(iceberg.OpIn,
iceberg.Reference(group.fieldName), group.literals[start:end])
+ if start == 0 {
+ terms[group.index] = term
+ } else {
+ terms = append(terms, term)
+ }
}
}
- if len(terms) == 0 {
- return iceberg.AlwaysTrue{}, nil
- }
-
- if len(terms) == 1 {
- return terms[0], nil
- }
-
- return iceberg.NewOr(terms[0], terms[1], terms[2:]...), nil
+ return balancedConflictDisjunction(terms), nil
}
// validateNoNewDeletesForRewrittenFiles rejects the commit if any
diff --git a/table/partition_conflict_bench_test.go
b/table/partition_conflict_bench_test.go
index 9ec32ff17..ebbc86ba3 100644
--- a/table/partition_conflict_bench_test.go
+++ b/table/partition_conflict_bench_test.go
@@ -35,9 +35,12 @@ func BenchmarkEqDeletePartitionsToFilter(b *testing.B) {
fileCount int
partitionCount int
}{
+ {fileCount: 1, partitionCount: 1},
+ {fileCount: 16, partitionCount: 16},
{fileCount: 1_000, partitionCount: 100},
{fileCount: 10_000, partitionCount: 100},
{fileCount: 1_000, partitionCount: 1_000},
+ {fileCount: 4_096, partitionCount: 4_096},
} {
name := fmt.Sprintf(
"fields=%d/files=%d/partitions=%d",
@@ -55,6 +58,9 @@ func BenchmarkEqDeletePartitionsToFilter(b *testing.B) {
b.Run("after", func(b *testing.B) {
benchmarkEqDeletePartitionFilter(b,
files, meta, eqDeletePartitionsToFilter)
})
+ b.Run("after_bind", func(b *testing.B) {
+
benchmarkEqDeletePartitionFilterAndBind(b, files, meta)
+ })
})
}
}
@@ -83,6 +89,27 @@ func benchmarkEqDeletePartitionFilter(
}
}
+// benchmarkEqDeletePartitionFilterAndBind includes schema binding in the
+// measured work without performing manifest reads or commit IO.
+func benchmarkEqDeletePartitionFilterAndBind(b *testing.B, files
[]iceberg.DataFile, meta Metadata) {
+ b.Helper()
+ b.ReportAllocs()
+ schema := meta.CurrentSchema()
+ b.ResetTimer()
+
+ for range b.N {
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ if err != nil {
+ b.Fatal(err)
+ }
+ bound, err := iceberg.BindExpr(schema, filter, true)
+ if err != nil {
+ b.Fatal(err)
+ }
+ equalityDeleteConflictFilterBenchmarkSink = bound
+ }
+}
+
func equalityDeleteConflictBenchmarkInput(
b *testing.B,
fieldCount, fileCount, partitionCount int,
diff --git a/table/partition_conflict_test.go b/table/partition_conflict_test.go
index c09018793..3b344cb18 100644
--- a/table/partition_conflict_test.go
+++ b/table/partition_conflict_test.go
@@ -21,9 +21,11 @@ import (
"bytes"
"context"
"fmt"
+ "math"
"path/filepath"
"testing"
+ "github.com/apache/arrow-go/v18/arrow/decimal128"
"github.com/apache/iceberg-go"
iceio "github.com/apache/iceberg-go/io"
"github.com/google/uuid"
@@ -241,10 +243,10 @@ func
TestEqDeletePartitionsToFilterDeduplicatesPartitions(t *testing.T) {
filter, err := eqDeletePartitionsToFilter(files, countingMeta)
require.NoError(t, err)
- expected := iceberg.NewOr(
- iceberg.EqualTo(iceberg.Reference("region"), "us-east-1"),
- iceberg.EqualTo(iceberg.Reference("region"), "eu-west-1"),
- )
+ expected := iceberg.SetPredicate(iceberg.OpIn,
iceberg.Reference("region"), []iceberg.Literal{
+ iceberg.StringLiteral("us-east-1"),
+ iceberg.StringLiteral("eu-west-1"),
+ })
assert.True(t, filter.Equals(expected), "duplicate equality-delete
partitions should produce one filter term")
assert.Equal(t, 1, countingMeta.currentSchemaCalls,
"the current schema should be cloned once per filter")
@@ -803,3 +805,181 @@ func
TestRowDeltaValidate_DayTransformFallsBackToAlwaysTrue(t *testing.T) {
require.Error(t, err, "day-partitioned eq-delete must trigger
conservative AlwaysTrue fallback even for a different day")
assert.ErrorIs(t, err, ErrConflictingDataFiles)
}
+
+func TestEqDeleteConflictSingleFieldWithNullAndSeparatedValues(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "region", Type:
iceberg.PrimitiveTypes.String},
+ )
+ meta := partitionedConflictMeta(t, schema, 1, nil)
+ spec := meta.PartitionSpec()
+ fieldID := spec.Field(0).FieldID
+
+ var files []iceberg.DataFile
+ values := []any{"west", "east", nil, "central"}
+ for i := range 32 {
+ value := values[i%len(values)]
+ files = append(files, equalityDeleteTestFile(t, spec,
+ fmt.Sprintf("delete-%d.parquet", i),
map[int]any{fieldID: value}))
+ }
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ require.NoError(t, err)
+
+ want := iceberg.NewOr(
+ iceberg.SetPredicate(iceberg.OpIn, iceberg.Reference("region"),
+ []iceberg.Literal{
+ iceberg.StringLiteral("west"),
+ iceberg.StringLiteral("east"),
+ iceberg.StringLiteral("central"),
+ }),
+ iceberg.IsNull(iceberg.Reference("region")),
+ )
+ require.True(t, filter.Equals(want), "single-field values must be
grouped without dropping nulls")
+ _, err = iceberg.BindExpr(meta.CurrentSchema(), filter, true)
+ require.NoError(t, err)
+}
+
+func TestEqDeleteConflictLargeMultiFieldPredicateBinds(t *testing.T) {
+ const fieldCount = 3
+ fields := make([]iceberg.NestedField, fieldCount)
+ partFields := make([]iceberg.PartitionField, fieldCount)
+ for i := range fields {
+ fields[i] = iceberg.NestedField{
+ ID: i + 1, Name: fmt.Sprintf("field_%d", i), Type:
iceberg.PrimitiveTypes.String,
+ }
+ partFields[i] = iceberg.PartitionField{
+ FieldID: 1000 + i, SourceIDs: []int{i + 1},
+ Name: fmt.Sprintf("field_%d", i), Transform:
iceberg.IdentityTransform{},
+ }
+ }
+ schema := iceberg.NewSchema(0, fields...)
+ spec := iceberg.NewPartitionSpec(partFields...)
+ meta, err := NewMetadata(schema, &spec, UnsortedSortOrder,
"file:///tmp/conflict-balanced",
+ iceberg.Properties{PropertyFormatVersion: "2"})
+ require.NoError(t, err)
+
+ files := make([]iceberg.DataFile, 256)
+ for i := range files {
+ p := make(map[int]any, fieldCount)
+ for field := range fieldCount {
+ p[1000+field] = fmt.Sprintf("v-%d-%d", i, field)
+ }
+ files[i] = equalityDeleteTestFile(t, spec,
fmt.Sprintf("del-%d.parquet", i), p)
+ }
+
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ require.NoError(t, err)
+ require.Equal(t, iceberg.OpOr, filter.Op())
+ _, err = iceberg.BindExpr(meta.CurrentSchema(), filter, true)
+ require.NoError(t, err)
+}
+
+func TestEqDeleteConflictLargeSparseSetPreservesMetricsPruning(t *testing.T) {
+ schema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1, Name:
"value", Type: iceberg.PrimitiveTypes.Int32})
+ meta := partitionedConflictMeta(t, schema, 1, nil)
+ spec := meta.PartitionSpec()
+ fieldID := spec.Field(0).FieldID
+ files := make([]iceberg.DataFile, 401)
+ for i := range files {
+ files[i] = equalityDeleteTestFile(t, spec,
fmt.Sprintf("delete-%d.parquet", i), map[int]any{fieldID: int32(i * 2)})
+ }
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ require.NoError(t, err)
+ evaluate, err := newInclusiveMetricsEvaluator(schema, filter, true,
true)
+ require.NoError(t, err)
+ for _, tc := range []struct {
+ value int32
+ want bool
+ }{{0, true}, {1, false}, {199, false}, {200, true}, {399, false}, {400,
true}, {799, false}, {800, true}, {801, false}} {
+ raw, err := iceberg.NewLiteral(tc.value).MarshalBinary()
+ require.NoError(t, err)
+ got, err := evaluate(inclusiveMetricsInTestFile(t, raw, raw))
+ require.NoError(t, err)
+ require.Equalf(t, tc.want, got, "value=%d", tc.value)
+ }
+}
+
+// NaN equality must remain an equality predicate: Go map lookup cannot find
+// a NaN key, so placing it in an IN set can miss a conflicting partition.
+func TestEqDeleteConflictNaNPartition(t *testing.T) {
+ schema := iceberg.NewSchema(0, iceberg.NestedField{ID: 1, Name:
"value", Type: iceberg.PrimitiveTypes.Float64})
+ meta := partitionedConflictMeta(t, schema, 1, nil)
+ spec := meta.PartitionSpec()
+ fieldID := spec.Field(0).FieldID
+ values := []float64{1, 2, 3, 4, 5, 6, 7, math.NaN()}
+ files := make([]iceberg.DataFile, 32)
+ for i := range files {
+ value := values[i%len(values)]
+ files[i] = equalityDeleteTestFile(t, spec,
fmt.Sprintf("delete-%d.parquet", i), map[int]any{fieldID: value})
+ }
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ require.NoError(t, err)
+ filters := newKeyDefaultMapWrapErr(func(id int)
(iceberg.BooleanExpression, error) {
+ return buildPartitionProjection(id, meta, schema, filter, true)
+ })
+ evaluate, err := buildPartitionEvaluator(spec.ID(), meta, schema,
filters, true)
+ require.NoError(t, err)
+ for _, i := range []int{0, 6, 7} {
+ got, err := evaluate(files[i])
+ require.NoError(t, err)
+ require.True(t, got, "partition %d must match", i)
+ }
+}
+
+func TestEqDeleteConflictDecimalPrecisionPreservesGrouping(t *testing.T) {
+ // DecimalLiteral.Type reports decimal(9, scale), even when the source
+ // column has higher precision. Grouping must use the actual source
scale.
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "amount", Type:
iceberg.MustDecimalTypeOf(18, 2)},
+ )
+ meta := partitionedConflictMeta(t, schema, 1, nil)
+ spec := meta.PartitionSpec()
+ fieldID := spec.Field(0).FieldID
+
+ values := []iceberg.Decimal{
+ {Val: decimal128.FromI64(123), Scale: 2},
+ {Val: decimal128.FromI64(456), Scale: 2},
+ {Val: decimal128.FromI64(789), Scale: 2},
+ }
+ files := make([]iceberg.DataFile, len(values))
+ literals := make([]iceberg.Literal, len(values))
+ for i, value := range values {
+ files[i] = equalityDeleteTestFile(t, spec,
fmt.Sprintf("decimal-%d.parquet", i),
+ map[int]any{fieldID: value})
+ literals[i] = iceberg.NewLiteral(value)
+ }
+
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ require.NoError(t, err)
+ want := iceberg.SetPredicate(iceberg.OpIn, iceberg.Reference("amount"),
literals)
+ require.True(t, filter.Equals(want), "decimal precision must not
disable safe IN grouping")
+
+ _, err = iceberg.BindExpr(meta.CurrentSchema(), filter, true)
+ require.NoError(t, err)
+}
+
+func TestEqDeleteConflictCastValuePreservesEqualityPredicate(t *testing.T) {
+ schema := iceberg.NewSchema(0,
+ iceberg.NestedField{ID: 1, Name: "value", Type:
iceberg.PrimitiveTypes.Float64},
+ )
+ meta := partitionedConflictMeta(t, schema, 1, nil)
+ spec := meta.PartitionSpec()
+ fieldID := spec.Field(0).FieldID
+
+ files := []iceberg.DataFile{
+ equalityDeleteTestFile(t, spec, "float-1.parquet",
map[int]any{fieldID: float64(1)}),
+ equalityDeleteTestFile(t, spec, "cast-nan.parquet",
map[int]any{fieldID: "NaN"}),
+ equalityDeleteTestFile(t, spec, "float-2.parquet",
map[int]any{fieldID: float64(2)}),
+ }
+
+ filter, err := eqDeletePartitionsToFilter(files, meta)
+ require.NoError(t, err)
+ want := iceberg.NewOr(
+ iceberg.SetPredicate(iceberg.OpIn, iceberg.Reference("value"),
[]iceberg.Literal{
+ iceberg.Float64Literal(1), iceberg.Float64Literal(2),
+ }),
+ iceberg.LiteralPredicate(iceberg.OpEQ,
iceberg.Reference("value"), iceberg.StringLiteral("NaN")),
+ )
+ require.True(t, filter.Equals(want), "cast values must retain
individual equality predicates")
+ _, err = iceberg.BindExpr(meta.CurrentSchema(), filter, true)
+ require.NoError(t, err)
+}