You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/04/29 20:36:38 UTC

(iceberg-go) branch main updated: fix: data races in exprEvaluator.Eval and manifestEvalVisitor.Eval (#954)

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 e3dceccd fix: data races in exprEvaluator.Eval and manifestEvalVisitor.Eval (#954)
e3dceccd is described below

commit e3dceccd5c55cac1c91b9aec7586c395e9c930b9
Author: Varun Venkatesh <11...@users.noreply.github.com>
AuthorDate: Wed Apr 29 13:36:33 2026 -0700

    fix: data races in exprEvaluator.Eval and manifestEvalVisitor.Eval (#954)
    
    ## Problem
    
    Two evaluator types share a receiver across concurrent goroutine calls,
    causing data races detected by `-race`.
    
    **Race 1 — `exprEvaluator.Eval`** (`visitors.go`)
    
    `table.Scan.collectManifestEntries` fans out manifest opens via
    `errgroup` (`scanner.go`), and each goroutine calls the bound
    `*exprEvaluator.Eval` method value returned by `ExpressionEvaluator`.
    `Eval` writes the per-call `StructLike` to the receiver's shared `st`
    field (`e.st = st`). Because the `*exprEvaluator` is shared across the
    goroutine pool, parallel `Eval` calls race on `e.st`.
    
    **Race 2 — `manifestEvalVisitor.Eval`** (`table/evaluators.go`)
    
    `newManifestEvaluator` returns a closure that captures a shared
    `*manifestEvalVisitor`. When multiple goroutines call that closure
    concurrently (e.g. the `errgroup` in `classifyFilesForFilteredDeletions`
    and parallel manifest scans in `collectManifestEntries`), they race on
    the `partitionFields` write in `Eval` vs. the reads inside
    `VisitLess`/`VisitGreater`.
    
    ## Fix
    
    Both fixes mirror the approach used in #445 (closes #443) for
    `inclusiveMetricsEval`: instead of mutating the shared receiver, `Eval`
    builds a per-call stack-local copy of the visitor and passes `&ev` to
    `VisitExpr`. The captured receiver is read-only after construction; all
    per-call mutable state lives in the local copy.
    
    ```go
    // exprEvaluator
    func (e *exprEvaluator) Eval(st StructLike) (bool, error) {
        ev := exprEvaluator{bound: e.bound, st: st}
        return VisitExpr(ev.bound, &ev)
    }
    
    // manifestEvalVisitor
    func (m *manifestEvalVisitor) Eval(manifest iceberg.ManifestFile) (bool, error) {
        parts := manifest.Partitions()
        if len(parts) == 0 {
            return rowsMightMatch, nil
        }
        ev := manifestEvalVisitor{partitionFields: parts, partitionFilter: m.partitionFilter}
        return VisitExpr(ev.partitionFilter, &ev)
    }
    ```
    
    No API changes.
    
    ## Testing
    
    - Adds `TestManifestEvalVisitorEvalRace` in
    `table/evaluator_race_test.go`: 256 goroutines share one cached
    evaluator closure and call it concurrently. Confirmed to trip `-race`
    before the fix and pass after.
    - Existing `TestKeyDefaultMapRaceCondition` (the only prior race test)
    still passes.
    - Full `go test -race ./table/ ./...` passes (pre-existing races in
    miniohq-specific tests are unrelated).
    
    ## Related
    
    - Sibling precedent: #445 (closes #443) — same pattern for
    `inclusiveMetricsEval`.
    
    ---------
    
    Co-authored-by: Varun Venkatesh <va...@Varuns-MacBook-Pro.local>
---
 table/evaluator_race_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++++
 table/evaluators.go          | 16 ++++++--
 visitors.go                  |  8 +++-
 3 files changed, 106 insertions(+), 6 deletions(-)

diff --git a/table/evaluator_race_test.go b/table/evaluator_race_test.go
new file mode 100644
index 00000000..5984ea01
--- /dev/null
+++ b/table/evaluator_race_test.go
@@ -0,0 +1,88 @@
+// 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
+
+package table
+
+import (
+	"sync"
+	"testing"
+
+	"github.com/apache/iceberg-go"
+)
+
+// TestManifestEvalVisitorEvalRace fans out 256 goroutines that all call the
+// same cached Eval closure concurrently. Under -race, any write to a shared
+// receiver field inside Eval (e.g. m.partitionFields = parts) plus a read
+// inside VisitGreater/VisitLess/etc. will trip the detector.
+//
+// This reproduces the same shared-receiver pattern fixed previously in
+// exprEvaluator: newManifestEvaluator returns (&manifestEvalVisitor{...}).Eval,
+// callers in transaction.go's classifyFilesForFilteredDeletions loop the
+// returned closure across an errgroup of goroutines, and every goroutine
+// races on the captured *manifestEvalVisitor.
+func TestManifestEvalVisitorEvalRace(t *testing.T) {
+	intMin := []byte{0x00, 0x00, 0x00, 0x00}
+	intMax := []byte{0x7f, 0x00, 0x00, 0x00}
+
+	schema := iceberg.NewSchema(0,
+		iceberg.NestedField{
+			ID: 1, Name: "id",
+			Type: iceberg.PrimitiveTypes.Int32, Required: false,
+		},
+	)
+
+	spec := iceberg.NewPartitionSpec(iceberg.PartitionField{
+		Name:      "id",
+		SourceIDs: []int{1},
+		FieldID:   1000,
+		Transform: iceberg.IdentityTransform{},
+	})
+
+	// Single shared evaluator — this is the analogue of
+	// manifestEvaluators.Get(specID) returning the same cached closure to
+	// every goroutine in the production callers.
+	eval, err := newManifestEvaluator(spec, schema,
+		iceberg.LessThan(iceberg.Reference("id"), int32(50)),
+		true)
+	if err != nil {
+		t.Fatalf("newManifestEvaluator: %v", err)
+	}
+
+	const goroutines = 256
+
+	manifests := make([]iceberg.ManifestFile, goroutines)
+	for i := range manifests {
+		manifests[i] = iceberg.NewManifestFile(1, "", 0, 0, int64(i)).Partitions(
+			[]iceberg.FieldSummary{
+				{
+					ContainsNull: false,
+					LowerBound:   &intMin,
+					UpperBound:   &intMax,
+				},
+			},
+		).Build()
+	}
+
+	start := make(chan struct{})
+	var wg sync.WaitGroup
+	wg.Add(goroutines)
+	for i := 0; i < goroutines; i++ {
+		i := i
+		go func() {
+			defer wg.Done()
+			<-start
+			if _, err := eval(manifests[i]); err != nil {
+				t.Errorf("eval[%d]: %v", i, err)
+			}
+		}()
+	}
+	close(start)
+	wg.Wait()
+}
diff --git a/table/evaluators.go b/table/evaluators.go
index 7dbfd030..40aaad84 100644
--- a/table/evaluators.go
+++ b/table/evaluators.go
@@ -60,13 +60,21 @@ type manifestEvalVisitor struct {
 }
 
 func (m *manifestEvalVisitor) Eval(manifest iceberg.ManifestFile) (bool, error) {
-	if parts := manifest.Partitions(); len(parts) > 0 {
-		m.partitionFields = parts
+	parts := manifest.Partitions()
+	if len(parts) == 0 {
+		return rowsMightMatch, nil
+	}
 
-		return iceberg.VisitExpr(m.partitionFilter, m)
+	// Use a per-call visitor so concurrent callers of the same cached Eval
+	// closure (e.g. the errgroup in classifyFilesForFilteredDeletions and
+	// the parallel manifest scans in scanner.collectManifestEntries) do
+	// not race on partitionFields. Mirrors the exprEvaluator fix above.
+	ev := manifestEvalVisitor{
+		partitionFields: parts,
+		partitionFilter: m.partitionFilter,
 	}
 
-	return rowsMightMatch, nil
+	return iceberg.VisitExpr(ev.partitionFilter, &ev)
 }
 
 func removeBoundCmp[T iceberg.LiteralType](bound iceberg.Literal, vals []iceberg.Literal, cmpToDelete int) []iceberg.Literal {
diff --git a/visitors.go b/visitors.go
index 9564a4ea..e0fd4dfd 100644
--- a/visitors.go
+++ b/visitors.go
@@ -201,9 +201,13 @@ type exprEvaluator struct {
 }
 
 func (e *exprEvaluator) Eval(st StructLike) (bool, error) {
-	e.st = st
+	// Use a per-call evaluator so concurrent callers (e.g. parallel
+	// manifest scans in table.Scan.collectManifestEntries) do not race
+	// on the shared `st` field. Mirrors the fix in inclusiveMetricsEval
+	// from #445.
+	ev := exprEvaluator{bound: e.bound, st: st}
 
-	return VisitExpr(e.bound, e)
+	return VisitExpr(ev.bound, &ev)
 }
 
 func (e *exprEvaluator) VisitUnbound(UnboundPredicate) bool {