laskoviymishka commented on code in PR #2117:
URL: https://github.com/apache/iceberg-go/pull/2117#discussion_r4193441068


##########
table/in_predicate_extrema_test.go:
##########
@@ -139,6 +140,107 @@ func TestManifestEvaluatorInPredicateExtrema(t 
*testing.T) {
        }
 }
 
+func TestInclusiveMetricsEvaluatorInPredicateExtrema(t *testing.T) {
+       encode := func(value int32) []byte {
+               encoded, err := iceberg.NewLiteral(value).MarshalBinary()
+               require.NoError(t, err)
+
+               return encoded
+       }
+
+       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Int32})
+       expr := iceberg.IsIn(iceberg.Reference("value"), int32(1), int32(100))

Review Comment:
   The set here is int32-only, so nothing exercises `getCmpLiteral(minLit)` on 
the types where it matters: string, decimal, binary, date/timestamp, and 
especially the variant `BoundExtract` and geo literals where `getCmpLiteral` 
panics on an unrecognized type. I'd add a table-driven case running a few of 
those through the fast path.
   
   Separately, none of these cases prove the extrema path is even taken, since 
every one returns the same answer through the slow `removeBoundCheck`. A 
regression in `boundSetExtremaRef` or the dispatcher would leave the whole 
suite green. I'd either call `VisitInWithExtrema` directly with explicit 
min/max in at least one case, or assert both entry points agree across the 
matrix the way the manifest test does.



##########
table/evaluators.go:
##########
@@ -1153,8 +1165,38 @@ func (m *inclusiveMetricsEval) VisitIn(t 
iceberg.BoundTerm, s iceberg.Set[iceber
                return rowsMightMatch
        }
 
-       values := s.Members()
-       if lowerBound, ok := m.boundFor(t, m.lowerBounds[fieldID]); ok {
+       hasExtrema := minLit != nil && maxLit != nil
+       var (
+               values                 []iceberg.Literal
+               lowerBound, upperBound iceberg.Literal
+               hasLowerBound          bool
+               hasUpperBound          bool
+       )
+       if hasExtrema {

Review Comment:
   I'd fetch both bounds once up top and gate the extrema prune in a single `if 
hasExtrema` block, then fall through to the unchanged `Members()` and 
`removeBoundCheck` tail. Right now the lower-bound NaN check and the `boundFor` 
fetch are duplicated across the `hasExtrema` and `else` branches, and 
`hasExtrema` gets tested a second time further down to decide whether to 
re-fetch the upper bound, so any future change to bound handling has to land in 
three places and the two paths are easy to drift apart.
   
   One thing to preserve: the current order means a lower-bound prune returns 
before the upper `boundFor` ever runs, and the `disjoint lower bound avoids 
malformed upper bound` test leans on exactly that. If we hoist the upper fetch 
to the top, that test's malformed `[]byte{1}` upper would now get decoded. So 
either keep the extrema prune ordered lower-then-upper so the short-circuit 
still skips the upper fetch, or pull the shared tail into a small 
`prunedByExtrema(t, fieldID, minLit, maxLit) bool` helper that leaves the 
existing body untouched. Either's fine, but I'd rather not have the fast path 
spliced into the middle of the old flow.



##########
table/in_predicate_extrema_bench_test.go:
##########
@@ -116,3 +119,93 @@ func benchmarkManifestIn[T iceberg.LiteralType](b 
*testing.B, values []T, lower,
                manifestInPredicateBenchmarkSink = matched
        }
 }
+
+func BenchmarkInclusiveMetricsEvalInPredicate(b *testing.B) {
+       for _, literalCount := range []int{10, 50, 200} {
+               values := make([]int32, literalCount)
+               for i := range values {
+                       values[i] = int32(i * 2)
+               }
+
+               maxValue := values[len(values)-1]
+               for _, bounds := range []struct {
+                       name  string
+                       lower int32
+                       upper int32
+               }{
+                       {name: "below-set", lower: -2, upper: -2},
+                       {name: "above-set", lower: maxValue + 2, upper: 
maxValue + 2},
+                       {name: "overlap", lower: values[0], upper: maxValue},
+                       {name: "sparse-gap", lower: values[literalCount/2] + 1, 
upper: values[literalCount/2] + 1},

Review Comment:
   The overlap and sparse-gap cases both land inside the set range, so they go 
through the slow `removeBoundCheck` and can't show any fast-path gain. Only 
below-set and above-set do. Worth a note here (or in the PR description) that 
these two are regression baselines rather than fast-path cases, so the numbers 
don't get miscredited.



##########
table/in_predicate_extrema_test.go:
##########
@@ -139,6 +140,107 @@ func TestManifestEvaluatorInPredicateExtrema(t 
*testing.T) {
        }
 }
 
+func TestInclusiveMetricsEvaluatorInPredicateExtrema(t *testing.T) {
+       encode := func(value int32) []byte {
+               encoded, err := iceberg.NewLiteral(value).MarshalBinary()
+               require.NoError(t, err)
+
+               return encoded
+       }
+
+       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Int32})
+       expr := iceberg.IsIn(iceberg.Reference("value"), int32(1), int32(100))
+       eval, err := newInclusiveMetricsEvaluator(schema, expr, true, true)
+       require.NoError(t, err)
+
+       tests := []struct {
+               name       string
+               lowerBound []byte
+               upperBound []byte
+               want       bool
+       }{
+               {name: "lower bound above set", lowerBound: encode(101), 
upperBound: encode(200), want: false},
+               {name: "upper bound below set", lowerBound: encode(-10), 
upperBound: encode(0), want: false},
+               {name: "sparse set has no member in range", lowerBound: 
encode(40), upperBound: encode(60), want: false},
+               {name: "overlapping range contains member", lowerBound: 
encode(50), upperBound: encode(100), want: true},
+               {name: "upper bound equals minimum literal", upperBound: 
encode(1), want: true},
+               {name: "lower bound equals maximum literal", lowerBound: 
encode(100), want: true},
+               {name: "missing bounds", want: true},
+               {name: "upper bound below set without lower bound", upperBound: 
encode(0), want: false},
+               {name: "lower bound above set without upper bound", lowerBound: 
encode(101), want: false},
+               {name: "disjoint lower bound avoids malformed upper bound", 
lowerBound: encode(101), upperBound: []byte{1}, want: false},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       file := inclusiveMetricsInTestFile(t, tt.lowerBound, 
tt.upperBound)
+                       got, err := eval(file)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+
+       t.Run("NaN bounds fail open", func(t *testing.T) {
+               nan, err := iceberg.NewLiteral(math.NaN()).MarshalBinary()
+               require.NoError(t, err)
+               floatSchema := iceberg.NewSchema(1, iceberg.NestedField{
+                       ID: 1, Name: "value", Type: 
iceberg.PrimitiveTypes.Float64,
+               })
+               floatEval, err := newInclusiveMetricsEvaluator(
+                       floatSchema, iceberg.IsIn(iceberg.Reference("value"), 
float64(1), float64(2)), true, true,
+               )
+               require.NoError(t, err)
+
+               got, err := floatEval(inclusiveMetricsInTestFile(t, nan, nan))
+               require.NoError(t, err)
+               assert.True(t, got)
+       })
+
+       t.Run("oversized set keeps existing fallback", func(t *testing.T) {
+               values := make([]int32, inPredicateLimit+1)
+               for i := range values {
+                       values[i] = int32(i)
+               }
+               largeEval, err := newInclusiveMetricsEvaluator(
+                       schema, iceberg.IsIn(iceberg.Reference("value"), 
values...), true, true,
+               )
+               require.NoError(t, err)
+
+               got, err := largeEval(inclusiveMetricsInTestFile(
+                       t, encode(int32(inPredicateLimit+1)), 
encode(int32(inPredicateLimit+1)),

Review Comment:
   This case is meant to prove the oversized-set fallback fires before the 
extrema prune, but as written it doesn't isolate that. I'd pick bounds clearly 
disjoint from the set's range so that if the `Len() > inPredicateLimit` guard 
regressed, the fast path would prune and flip the expected answer to false. 
As-is a missing guard could still pass.



##########
table/in_predicate_extrema_bench_test.go:
##########
@@ -116,3 +119,93 @@ func benchmarkManifestIn[T iceberg.LiteralType](b 
*testing.B, values []T, lower,
                manifestInPredicateBenchmarkSink = matched
        }
 }
+
+func BenchmarkInclusiveMetricsEvalInPredicate(b *testing.B) {
+       for _, literalCount := range []int{10, 50, 200} {
+               values := make([]int32, literalCount)
+               for i := range values {
+                       values[i] = int32(i * 2)
+               }
+
+               maxValue := values[len(values)-1]
+               for _, bounds := range []struct {
+                       name  string
+                       lower int32
+                       upper int32
+               }{
+                       {name: "below-set", lower: -2, upper: -2},
+                       {name: "above-set", lower: maxValue + 2, upper: 
maxValue + 2},
+                       {name: "overlap", lower: values[0], upper: maxValue},
+                       {name: "sparse-gap", lower: values[literalCount/2] + 1, 
upper: values[literalCount/2] + 1},
+               } {
+                       
b.Run("int32/in="+strconv.Itoa(literalCount)+"/"+bounds.name, func(b 
*testing.B) {
+                               benchmarkInclusiveMetricsIn(b, values, 
bounds.lower, bounds.upper, literalCount, bounds.name == "overlap")
+                       })
+               }
+       }
+}
+
+func benchmarkInclusiveMetricsIn(b *testing.B, values []int32, lower, upper 
int32, literalCount int, wantMatch bool) {
+       b.Helper()
+
+       lowerBytes, err := iceberg.NewLiteral(lower).MarshalBinary()
+       if err != nil {
+               b.Fatal(err)
+       }
+       upperBytes, err := iceberg.NewLiteral(upper).MarshalBinary()
+       if err != nil {
+               b.Fatal(err)
+       }
+
+       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Int32})
+       eval, err := newInclusiveMetricsEvaluator(
+               schema, iceberg.IsIn(iceberg.Reference("value"), values...), 
true, true,
+       )
+       if err != nil {
+               b.Fatal(err)
+       }
+
+       builder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentData, 
"file.parquet", iceberg.ParquetFile,
+               nil, nil, nil, 10, 100,
+       )
+       if err != nil {
+               b.Fatal(err)
+       }
+       file := builder.
+               ValueCounts(map[int]int64{1: 10}).
+               NullValueCounts(map[int]int64{1: 0}).
+               NaNValueCounts(map[int]int64{1: 0}).
+               LowerBoundValues(map[int][]byte{1: lowerBytes}).
+               UpperBoundValues(map[int][]byte{1: upperBytes}).
+               Build()
+       files := make([]iceberg.DataFile, 10_000)
+       for i := range files {
+               files[i] = file
+       }
+
+       b.ReportAllocs()
+       b.ReportMetric(float64(len(files)), "files/op")
+       b.ReportMetric(float64(literalCount), "literals/file")
+       b.ResetTimer()
+       for range b.N {
+               matchedFiles := 0
+               for _, file := range files {

Review Comment:
   Couple of small things in the bench: the inner `for _, file := range files` 
shadows the outer `file`, which is legal but confusing, so I'd rename it to 
`f`. And `wantMatch` is derived from `bounds.name == "overlap"` at the call 
site; I'd put a `wantMatch bool` field on the bounds case struct instead so the 
expected result lives with the data.



##########
table/evaluators.go:
##########
@@ -1142,6 +1142,18 @@ func (m *inclusiveMetricsEval) 
VisitNotEqual(iceberg.BoundTerm, iceberg.Literal)
 }
 
 func (m *inclusiveMetricsEval) VisitIn(t iceberg.BoundTerm, s 
iceberg.Set[iceberg.Literal]) bool {
+       return m.visitIn(t, s, nil, nil)
+}
+
+func (m *inclusiveMetricsEval) VisitInWithExtrema(

Review Comment:
   Worth a one-line doc comment spelling out the contract: both `minLit` and 
`maxLit` must be non-nil (and the actual min/max of `s`), or both nil to get 
the slow path. Right now a partial-nil call silently falls back, and 
`manifestEvalVisitor.visitIn` treats the two independently, so the two extrema 
implementations have different partial-nil semantics. I'd align them or note 
the difference in the comment.



##########
table/in_predicate_extrema_test.go:
##########
@@ -139,6 +140,107 @@ func TestManifestEvaluatorInPredicateExtrema(t 
*testing.T) {
        }
 }
 
+func TestInclusiveMetricsEvaluatorInPredicateExtrema(t *testing.T) {
+       encode := func(value int32) []byte {
+               encoded, err := iceberg.NewLiteral(value).MarshalBinary()
+               require.NoError(t, err)
+
+               return encoded
+       }
+
+       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 1, Name: 
"value", Type: iceberg.PrimitiveTypes.Int32})
+       expr := iceberg.IsIn(iceberg.Reference("value"), int32(1), int32(100))
+       eval, err := newInclusiveMetricsEvaluator(schema, expr, true, true)
+       require.NoError(t, err)
+
+       tests := []struct {
+               name       string
+               lowerBound []byte
+               upperBound []byte
+               want       bool
+       }{
+               {name: "lower bound above set", lowerBound: encode(101), 
upperBound: encode(200), want: false},
+               {name: "upper bound below set", lowerBound: encode(-10), 
upperBound: encode(0), want: false},
+               {name: "sparse set has no member in range", lowerBound: 
encode(40), upperBound: encode(60), want: false},
+               {name: "overlapping range contains member", lowerBound: 
encode(50), upperBound: encode(100), want: true},
+               {name: "upper bound equals minimum literal", upperBound: 
encode(1), want: true},
+               {name: "lower bound equals maximum literal", lowerBound: 
encode(100), want: true},
+               {name: "missing bounds", want: true},
+               {name: "upper bound below set without lower bound", upperBound: 
encode(0), want: false},
+               {name: "lower bound above set without upper bound", lowerBound: 
encode(101), want: false},
+               {name: "disjoint lower bound avoids malformed upper bound", 
lowerBound: encode(101), upperBound: []byte{1}, want: false},
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       file := inclusiveMetricsInTestFile(t, tt.lowerBound, 
tt.upperBound)
+                       got, err := eval(file)
+                       require.NoError(t, err)
+                       assert.Equal(t, tt.want, got)
+               })
+       }
+
+       t.Run("NaN bounds fail open", func(t *testing.T) {

Review Comment:
   The NaN coverage only hits the both-bounds-NaN case. The 
`!m.isNan(upperBound)` skip in the extrema branch (a NaN upper with a finite 
lower) isn't exercised by anything, so I'd add that case plus the mirror 
finite-upper/NaN-lower so both fail-open branches are pinned.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to