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


##########
table/evaluators.go:
##########
@@ -326,6 +330,52 @@ func getCmpLiteral(boundary iceberg.Literal) 
func(iceberg.Literal, iceberg.Liter
        panic(iceberg.ErrType)
 }
 
+// compareLiteralValues requires the same concrete literal type on both sides,
+// as guaranteed for bound predicates. A mismatched type assertion will panic.
+func compareLiteralValues[T iceberg.LiteralType](left, right iceberg.Literal) 
int {
+       leftValue := left.(iceberg.TypedLiteral[T])
+       rightValue := right.(iceberg.TypedLiteral[T])
+
+       return leftValue.Comparator()(leftValue.Value(), rightValue.Value())
+}
+
+// Keep this dispatch aligned with getCmpLiteral, removeBoundCheck, and
+// allBoundCheck when adding a new literal type.
+func compareBoundLiterals(left, right iceberg.Literal) int {

Review Comment:
   The "keep aligned" comment lands, but a new literal type added to one switch 
and not this one still only shows up as a runtime panic. A small table test 
that runs each primitive type through both `compareBoundLiterals` and 
`getCmpLiteral` and asserts they agree would pin the drift mechanically. If 
you'd rather, reimplementing `getCmpLiteral` as a thin wrapper over 
`compareBoundLiterals` drops one of the four switches outright and removes the 
per-file closure alloc that zeroshade's thread is about.



##########
table/evaluators.go:
##########
@@ -1142,34 +1192,81 @@ 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)
+}
+
+// VisitInWithExtrema expects minLit and maxLit to either both be the actual
+// extrema of s or both be nil. A partial pair is treated as unavailable and
+// follows the same path as VisitIn.
+func (m *inclusiveMetricsEval) VisitInWithExtrema(
+       t iceberg.BoundTerm, s iceberg.Set[iceberg.Literal], minLit, maxLit 
iceberg.Literal,
+) bool {
+       return m.visitIn(t, s, minLit, maxLit)
+}
+
+func (m *inclusiveMetricsEval) visitIn(
+       t iceberg.BoundTerm, s iceberg.Set[iceberg.Literal], minLit, maxLit 
iceberg.Literal,
+) bool {
        fieldID := t.Ref().Field().ID
 
        if m.containsNullsOnly(fieldID) || m.containsNansOnly(fieldID) {
                return rowsCannotMatch
        }
 
-       if s.Len() > inPredicateLimit {
-               // skip evaluating the predicate if the number of values is too 
big
+       hasExtrema := minLit != nil && maxLit != nil
+       oversized := s.Len() > inPredicateLimit
+       if oversized && !hasExtrema {
+               // Skip evaluating the predicate if the number of values is too 
big.
                return rowsMightMatch
        }
 
-       values := s.Members()
-       if lowerBound, ok := m.boundFor(t, m.lowerBounds[fieldID]); ok {
+       var values []iceberg.Literal
+       lowerBound, hasLowerBound := m.boundFor(t, m.lowerBounds[fieldID])
+       if hasLowerBound {
                if m.isNan(lowerBound) {
                        return rowsMightMatch
                }
-
-               values = removeBoundCheck(lowerBound, values, 1)
-               if len(values) == 0 {
+               if hasExtrema && compareBoundLiterals(lowerBound, maxLit) > 0 {
+                       // Short-circuit before decoding an unnecessary 
(possibly invalid) upper bound.
                        return rowsCannotMatch
                }
+               if !hasExtrema {
+                       // The slow path must finish the lower scan before 
reading the upper bound.
+                       values = removeBoundCheck(lowerBound, s.Members(), 1)
+                       if len(values) == 0 {
+                               return rowsCannotMatch
+                       }
+               }
        }
 
-       if upperBound, ok := m.boundFor(t, m.upperBounds[fieldID]); ok {
+       upperBound, hasUpperBound := m.boundFor(t, m.upperBounds[fieldID])
+       if hasUpperBound {
                if m.isNan(upperBound) {
                        return rowsMightMatch
                }
+               if hasExtrema && compareBoundLiterals(upperBound, minLit) < 0 {
+                       return rowsCannotMatch
+               }
+       }
+
+       if oversized {
+               // Unlike Java's InclusiveEvalVisitor, we still prune disjoint 
oversized

Review Comment:
   This closes the Java-divergence comment from last round, thanks. One wording 
thing: "the manifest evaluator handles its size limit in a separate branch" 
reads as if the manifest matches Java, but its `hasExtrema` branch prunes 
oversized sets the same way this one does. I'd reword to say both evaluators 
prune oversized IN sets on extrema, and drop the equivalent one-liner on the 
manifest branch so the two read consistently.



##########
table/evaluators.go:
##########
@@ -326,6 +330,52 @@ func getCmpLiteral(boundary iceberg.Literal) 
func(iceberg.Literal, iceberg.Liter
        panic(iceberg.ErrType)
 }
 
+// compareLiteralValues requires the same concrete literal type on both sides,
+// as guaranteed for bound predicates. A mismatched type assertion will panic.
+func compareLiteralValues[T iceberg.LiteralType](left, right iceberg.Literal) 
int {
+       leftValue := left.(iceberg.TypedLiteral[T])
+       rightValue := right.(iceberg.TypedLiteral[T])
+
+       return leftValue.Comparator()(leftValue.Value(), rightValue.Value())
+}
+
+// Keep this dispatch aligned with getCmpLiteral, removeBoundCheck, and
+// allBoundCheck when adding a new literal type.
+func compareBoundLiterals(left, right iceberg.Literal) int {
+       switch left.(type) {
+       case iceberg.TypedLiteral[bool]:
+               return compareLiteralValues[bool](left, right)
+       case iceberg.TypedLiteral[int32]:
+               return compareLiteralValues[int32](left, right)
+       case iceberg.TypedLiteral[int64]:
+               return compareLiteralValues[int64](left, right)
+       case iceberg.TypedLiteral[float32]:
+               return compareLiteralValues[float32](left, right)
+       case iceberg.TypedLiteral[float64]:
+               return compareLiteralValues[float64](left, right)
+       case iceberg.TypedLiteral[iceberg.Date]:
+               return compareLiteralValues[iceberg.Date](left, right)
+       case iceberg.TypedLiteral[iceberg.Time]:
+               return compareLiteralValues[iceberg.Time](left, right)
+       case iceberg.TypedLiteral[iceberg.Timestamp]:
+               return compareLiteralValues[iceberg.Timestamp](left, right)
+       case iceberg.TypedLiteral[iceberg.TimestampNano]:
+               return compareLiteralValues[iceberg.TimestampNano](left, right)
+       case iceberg.TypedLiteral[[]byte]:
+               return compareLiteralValues[[]byte](left, right)
+       case iceberg.TypedLiteral[string]:
+               return compareLiteralValues[string](left, right)
+       case iceberg.TypedLiteral[uuid.UUID]:
+               return compareLiteralValues[uuid.UUID](left, right)
+       case iceberg.TypedLiteral[iceberg.Decimal]:
+               return compareLiteralValues[iceberg.Decimal](left, right)
+       case iceberg.GeoLiteral:

Review Comment:
   `TestVisitBoundPredicateRefGeoSkipsExtrema` covers the gating, so that item 
landed. This panic is only unreachable because that gating keeps geo off the 
extrema path, which is worth a one-line note here since the safety is otherwise 
implicit. The gating test also proves no extrema are passed but not that a real 
geo IN through `newInclusiveMetricsEvaluator` takes `VisitIn` rather than 
reaching this line; one end-to-end run would close that.



##########
table/in_predicate_extrema_test.go:
##########
@@ -139,6 +141,466 @@ 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) {
+               encodeFloat := func(value float64) []byte {
+                       encoded, err := 
iceberg.NewLiteral(value).MarshalBinary()
+                       require.NoError(t, err)
+
+                       return encoded
+               }
+               nan := encodeFloat(math.NaN())
+               finiteLower := encodeFloat(0)
+               finiteUpper := encodeFloat(3)
+               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)
+
+               for _, bounds := range []struct {
+                       name         string
+                       lower, upper []byte
+               }{
+                       {name: "both NaN", lower: nan, upper: nan},
+                       {name: "NaN lower finite upper", lower: nan, upper: 
finiteUpper},
+                       {name: "finite lower NaN upper", lower: finiteLower, 
upper: nan},
+               } {
+                       t.Run(bounds.name, func(t *testing.T) {
+                               got, err := 
floatEval(inclusiveMetricsInTestFile(t, bounds.lower, bounds.upper))
+                               require.NoError(t, err)
+                               assert.True(t, got)
+                       })
+               }
+       })
+
+       t.Run("oversized set still uses extrema before member scan limit", 
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)
+
+               disjointLower := int32(inPredicateLimit + 1000)
+               got, err := largeEval(inclusiveMetricsInTestFile(
+                       t, encode(disjointLower), encode(disjointLower+1),
+               ))
+               require.NoError(t, err)
+               assert.False(t, got)
+
+               got, err = largeEval(inclusiveMetricsInTestFile(t, encode(50), 
encode(60)))
+               require.NoError(t, err)
+               assert.True(t, got)
+       })
+}
+
+func TestInclusiveMetricsEvaluatorInPredicateExtremaFastPathTypes(t 
*testing.T) {
+       decimal := func(value int64) iceberg.Decimal {
+               return iceberg.Decimal{Val: decimal128.FromI64(value), Scale: 2}
+       }
+
+       tests := []struct {
+               name           string
+               typ            iceberg.Type
+               expr           iceberg.BooleanExpression
+               minLit, maxLit iceberg.Literal
+               lower, upper   iceberg.Literal
+               want           bool
+       }{
+               {
+                       name:   "string lower disjoint",
+                       typ:    iceberg.PrimitiveTypes.String,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), "a", 
"b"),
+                       minLit: iceberg.NewLiteral("a"), maxLit: 
iceberg.NewLiteral("b"),
+                       lower: iceberg.NewLiteral("m"), upper: 
iceberg.NewLiteral("z"),
+               },
+               {
+                       name:   "string overlaps",
+                       typ:    iceberg.PrimitiveTypes.String,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), "a", 
"b"),
+                       minLit: iceberg.NewLiteral("a"), maxLit: 
iceberg.NewLiteral("b"),
+                       lower: iceberg.NewLiteral("a"), upper: 
iceberg.NewLiteral("a"),
+                       want: true,
+               },
+               {
+                       name:   "decimal upper disjoint",
+                       typ:    iceberg.DecimalTypeOf(12, 2),
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
decimal(100), decimal(200)),
+                       minLit: iceberg.NewLiteral(decimal(100)), maxLit: 
iceberg.NewLiteral(decimal(200)),
+                       lower: iceberg.NewLiteral(decimal(-200)), upper: 
iceberg.NewLiteral(decimal(0)),
+               },
+               {
+                       name:   "decimal overlaps",
+                       typ:    iceberg.DecimalTypeOf(12, 2),
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
decimal(100), decimal(200)),
+                       minLit: iceberg.NewLiteral(decimal(100)), maxLit: 
iceberg.NewLiteral(decimal(200)),
+                       lower: iceberg.NewLiteral(decimal(100)), upper: 
iceberg.NewLiteral(decimal(150)),
+                       want: true,
+               },
+               {
+                       name:   "binary lower disjoint",
+                       typ:    iceberg.PrimitiveTypes.Binary,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
[]byte{1}, []byte{2}),
+                       minLit: iceberg.NewLiteral([]byte{1}), maxLit: 
iceberg.NewLiteral([]byte{2}),
+                       lower: iceberg.NewLiteral([]byte{10}), upper: 
iceberg.NewLiteral([]byte{20}),
+               },
+               {
+                       name:   "binary overlaps",
+                       typ:    iceberg.PrimitiveTypes.Binary,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
[]byte{1}, []byte{2}),
+                       minLit: iceberg.NewLiteral([]byte{1}), maxLit: 
iceberg.NewLiteral([]byte{2}),
+                       lower: iceberg.NewLiteral([]byte{1}), upper: 
iceberg.NewLiteral([]byte{1}),
+                       want: true,
+               },
+               {
+                       name:   "date upper disjoint",
+                       typ:    iceberg.PrimitiveTypes.Date,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
iceberg.Date(10), iceberg.Date(20)),
+                       minLit: iceberg.NewLiteral(iceberg.Date(10)), maxLit: 
iceberg.NewLiteral(iceberg.Date(20)),
+                       lower: iceberg.NewLiteral(iceberg.Date(-10)), upper: 
iceberg.NewLiteral(iceberg.Date(0)),
+               },
+               {
+                       name:   "date overlaps",
+                       typ:    iceberg.PrimitiveTypes.Date,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
iceberg.Date(10), iceberg.Date(20)),
+                       minLit: iceberg.NewLiteral(iceberg.Date(10)), maxLit: 
iceberg.NewLiteral(iceberg.Date(20)),
+                       lower: iceberg.NewLiteral(iceberg.Date(10)), upper: 
iceberg.NewLiteral(iceberg.Date(15)),
+                       want: true,
+               },
+               {
+                       name:   "timestamp lower disjoint",
+                       typ:    iceberg.PrimitiveTypes.Timestamp,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
iceberg.Timestamp(10), iceberg.Timestamp(20)),
+                       minLit: iceberg.NewLiteral(iceberg.Timestamp(10)), 
maxLit: iceberg.NewLiteral(iceberg.Timestamp(20)),
+                       lower: iceberg.NewLiteral(iceberg.Timestamp(30)), 
upper: iceberg.NewLiteral(iceberg.Timestamp(40)),
+               },
+               {
+                       name:   "timestamp overlaps",
+                       typ:    iceberg.PrimitiveTypes.Timestamp,
+                       expr:   iceberg.IsIn(iceberg.Reference("value"), 
iceberg.Timestamp(10), iceberg.Timestamp(20)),
+                       minLit: iceberg.NewLiteral(iceberg.Timestamp(10)), 
maxLit: iceberg.NewLiteral(iceberg.Timestamp(20)),
+                       lower: iceberg.NewLiteral(iceberg.Timestamp(10)), 
upper: iceberg.NewLiteral(iceberg.Timestamp(15)),
+                       want: true,
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 
1, Name: "value", Type: tt.typ})
+                       bound, err := iceberg.BindExpr(schema, tt.expr, true)
+                       require.NoError(t, err)
+                       pred, ok := bound.(iceberg.BoundSetPredicate)
+                       require.True(t, ok)
+
+                       lower, err := tt.lower.MarshalBinary()
+                       require.NoError(t, err)
+                       upper, err := tt.upper.MarshalBinary()
+                       require.NoError(t, err)
+                       newVisitor := func() *inclusiveMetricsEval {
+                               return &inclusiveMetricsEval{metricsEvaluator: 
metricsEvaluator{
+                                       valueCounts: map[int]int64{1: 10},
+                                       nullCounts:  map[int]int64{1: 0},
+                                       nanCounts:   map[int]int64{1: 0},
+                                       lowerBounds: map[int][]byte{1: lower},
+                                       upperBounds: map[int][]byte{1: upper},
+                               }}
+                       }
+
+                       slow := newVisitor().VisitIn(pred.Term(), 
pred.Literals())
+                       fast := newVisitor().VisitInWithExtrema(pred.Term(), 
pred.Literals(), tt.minLit, tt.maxLit)
+                       require.Equal(t, slow, fast)
+                       require.Equal(t, tt.want, fast)
+               })
+       }
+}
+
+type inExtremaTrackingSet struct {
+       iceberg.Set[iceberg.Literal]
+       memberCalls int
+}
+
+func (s *inExtremaTrackingSet) Members() []iceberg.Literal {
+       s.memberCalls++
+
+       return s.Set.Members()
+}
+
+func TestInclusiveMetricsEvaluatorInPredicateExtremaUsesFastPath(t *testing.T) 
{
+       decimal := func(value int64) iceberg.Decimal {
+               return iceberg.Decimal{Val: decimal128.FromI64(value), Scale: 2}
+       }
+       // Both paths must prune these disjoint files. The extrema path must do 
so
+       // without materializing the set members, using the actual bound 
extrema.
+       tests := []struct {
+               name           string
+               typ            iceberg.Type
+               bounds         []iceberg.Literal
+               minLit, maxLit iceberg.Literal
+       }{
+               {
+                       "string", iceberg.PrimitiveTypes.String,
+                       []iceberg.Literal{iceberg.NewLiteral("m"), 
iceberg.NewLiteral("n")},
+                       iceberg.NewLiteral("a"), iceberg.NewLiteral("b"),
+               },
+               {
+                       "decimal", iceberg.DecimalTypeOf(12, 2),
+                       []iceberg.Literal{iceberg.NewLiteral(decimal(5000)), 
iceberg.NewLiteral(decimal(6000))},
+                       iceberg.NewLiteral(decimal(100)), 
iceberg.NewLiteral(decimal(200)),
+               },
+               {
+                       "binary", iceberg.PrimitiveTypes.Binary,
+                       []iceberg.Literal{iceberg.NewLiteral([]byte("m")), 
iceberg.NewLiteral([]byte("n"))},
+                       iceberg.NewLiteral([]byte("a")), 
iceberg.NewLiteral([]byte("b")),
+               },
+               {
+                       "date", iceberg.PrimitiveTypes.Date,
+                       []iceberg.Literal{iceberg.NewLiteral(iceberg.Date(50)), 
iceberg.NewLiteral(iceberg.Date(60))},
+                       iceberg.NewLiteral(iceberg.Date(1)), 
iceberg.NewLiteral(iceberg.Date(2)),
+               },
+               {
+                       "timestamp", iceberg.PrimitiveTypes.Timestamp,
+                       
[]iceberg.Literal{iceberg.NewLiteral(iceberg.Timestamp(50)), 
iceberg.NewLiteral(iceberg.Timestamp(60))},
+                       iceberg.NewLiteral(iceberg.Timestamp(1)), 
iceberg.NewLiteral(iceberg.Timestamp(2)),
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       schema := iceberg.NewSchema(1, iceberg.NestedField{ID: 
1, Name: "value", Type: tt.typ})
+                       bound, err := iceberg.BindExpr(schema, 
iceberg.SetPredicate(
+                               iceberg.OpIn, iceberg.Reference("value"), 
[]iceberg.Literal{tt.minLit, tt.maxLit},
+                       ), true)
+                       require.NoError(t, err)
+                       pred, ok := bound.(iceberg.BoundSetPredicate)
+                       require.True(t, ok)
+
+                       lower, err := tt.bounds[0].MarshalBinary()
+                       require.NoError(t, err)
+                       upper, err := tt.bounds[1].MarshalBinary()
+                       require.NoError(t, err)
+                       newVisitor := func() *inclusiveMetricsEval {
+                               return &inclusiveMetricsEval{metricsEvaluator: 
metricsEvaluator{
+                                       valueCounts: map[int]int64{1: 10},
+                                       nullCounts:  map[int]int64{1: 0},
+                                       nanCounts:   map[int]int64{1: 0},
+                                       lowerBounds: map[int][]byte{1: lower},
+                                       upperBounds: map[int][]byte{1: upper},
+                               }}
+                       }
+
+                       slowSet := &inExtremaTrackingSet{Set: pred.Literals()}
+                       require.False(t, newVisitor().VisitIn(pred.Term(), 
slowSet))
+                       require.Equal(t, 1, slowSet.memberCalls)
+
+                       fastSet := &inExtremaTrackingSet{Set: pred.Literals()}

Review Comment:
   This proves the member scan was skipped, which is the real improvement over 
last round, but every row still feeds the set's true extrema, so a regression 
that ignored the passed `minLit`/`maxLit` and recomputed from `s` would stay 
green. That's the lying-extrema case I flagged before. I'd add one row where 
the extrema deliberately disagree with the set (set `{1,100}`, file bounds 
`[50,60]`, passed extrema `(70,80)`) and assert the extrema-derived answer, and 
while you're here an overlap row asserting `memberCalls == 1` covers the 
non-prunable path these rows never reach.



-- 
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