This is an automated email from the ASF dual-hosted git repository.

szehon-ho pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new bccd21e040 Spark: Fix concurrent runtime filtering on shared scan for 
COW UPDATE with subquery (#18005)
bccd21e040 is described below

commit bccd21e040a8caf0f084d167dd68a481f85c4423
Author: hemanthboyina <[email protected]>
AuthorDate: Thu Oct 8 08:18:12 2026 +0530

    Spark: Fix concurrent runtime filtering on shared scan for COW UPDATE with 
subquery (#18005)
---
 .../iceberg/spark/source/SparkBatchQueryScan.java  |   2 +-
 .../iceberg/spark/source/SparkCopyOnWriteScan.java |   2 +-
 .../spark/source/SparkPartitioningAwareScan.java   |   2 +-
 .../TestSparkCopyOnWriteScanConcurrency.java       | 194 +++++++++++++++++++
 .../iceberg/spark/source/SparkBatchQueryScan.java  |   2 +-
 .../iceberg/spark/source/SparkCopyOnWriteScan.java |   2 +-
 .../spark/source/SparkPartitioningAwareScan.java   |   2 +-
 .../TestSparkCopyOnWriteScanConcurrency.java       | 194 +++++++++++++++++++
 .../iceberg/spark/source/SparkCopyOnWriteScan.java |   2 +-
 .../spark/source/SparkPartitioningAwareScan.java   |   2 +-
 .../spark/source/SparkRuntimeFilterableScan.java   |   2 +-
 .../TestSparkCopyOnWriteScanConcurrency.java       | 205 ++++++++++++++++++++
 .../iceberg/spark/source/SparkCopyOnWriteScan.java |   2 +-
 .../spark/source/SparkPartitioningAwareScan.java   |   2 +-
 .../spark/source/SparkRuntimeFilterableScan.java   |   2 +-
 .../TestSparkCopyOnWriteScanConcurrency.java       | 206 +++++++++++++++++++++
 16 files changed, 811 insertions(+), 12 deletions(-)

diff --git 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
index a361a7f1ba..69fc4e2425 100644
--- 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
+++ 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
@@ -124,7 +124,7 @@ class SparkBatchQueryScan extends 
SparkPartitioningAwareScan<PartitionScanTask>
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     Expression runtimeFilterExpr = convertRuntimeFilters(predicates);
 
     if (runtimeFilterExpr != Expressions.alwaysTrue()) {
diff --git 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
index 9674a7333f..72b427ad53 100644
--- 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
+++ 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
@@ -118,7 +118,7 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     Preconditions.checkState(
         Objects.equals(snapshotId(), currentSnapshotId()),
         "Runtime file filtering is not possible: the table has been 
concurrently modified. "
diff --git 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
index 4d9fb7556b..7dad233895 100644
--- 
a/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
+++ 
b/spark/v3.5/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
@@ -243,7 +243,7 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
 
   // only task groups can be reset while resetting tasks
   // the set of scanned specs and grouping key type must never change
-  protected void resetTasks(List<T> filteredTasks) {
+  protected synchronized void resetTasks(List<T> filteredTasks) {
     this.taskGroups = null;
     this.tasks = filteredTasks;
   }
diff --git 
a/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
 
b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
new file mode 100644
index 0000000000..0182cb3c01
--- /dev/null
+++ 
b/spark/v3.5/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
@@ -0,0 +1,194 @@
+/*
+ * 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 org.apache.iceberg.spark.source;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import org.apache.iceberg.BatchScan;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.metrics.ScanReport;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.spark.SparkReadConf;
+import org.apache.iceberg.spark.TestBaseWithCatalog;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.FieldReference;
+import org.apache.spark.sql.connector.expressions.LiteralValue;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.filter.Predicate;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.unsafe.types.UTF8String;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.Timeout;
+
+public class TestSparkCopyOnWriteScanConcurrency extends TestBaseWithCatalog {
+
+  private static final long TIMEOUT_SECONDS = 10;
+
+  @AfterEach
+  public void removeTables() {
+    sql("DROP TABLE IF EXISTS %s", tableName);
+  }
+
+  @TestTemplate
+  @Timeout(value = 60, unit = TimeUnit.SECONDS)
+  public void concurrentRuntimeFilteringNarrowsSharedScan() throws Exception {
+    sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+    sql("INSERT INTO %s VALUES (2, 'b')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    CountDownLatch enteredResetTasks = new CountDownLatch(1);
+    CountDownLatch releaseFilter = new CountDownLatch(1);
+    InstrumentedScan scan = newScan(table, enteredResetTasks, releaseFilter);
+
+    List<String> locations =
+        scan.tasks().stream().map(task -> 
task.file().location()).collect(Collectors.toList());
+    assertThat(locations).hasSize(2);
+    assertThat(
+            scan.taskGroups().stream()
+                .flatMap(group -> group.tasks().stream())
+                .map(task -> task.file().location())
+                .collect(Collectors.toList()))
+        .containsExactlyInAnyOrderElementsOf(locations);
+    String location = locations.get(0);
+    NamedReference fileRef = 
FieldReference.apply(MetadataColumns.FILE_PATH.name());
+    LiteralValue<UTF8String> literal =
+        new LiteralValue<>(UTF8String.fromString(location), 
DataTypes.StringType);
+    Predicate[] predicates = {
+      new Predicate(
+          "IN", new org.apache.spark.sql.connector.expressions.Expression[] 
{fileRef, literal})
+    };
+
+    FutureTask<Void> winnerTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              return null;
+            });
+    FutureTask<Void> loserTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              assertThat(scan.tasks())
+                  .extracting(task -> task.file().location())
+                  .containsExactly(location);
+              assertThat(
+                      scan.taskGroups().stream()
+                          .flatMap(group -> group.tasks().stream())
+                          .map(task -> task.file().location())
+                          .collect(Collectors.toList()))
+                  .containsExactly(location);
+              return null;
+            });
+    Thread winner = new Thread(winnerTask);
+    Thread loser = new Thread(loserTask);
+
+    winner.start();
+    assertThat(enteredResetTasks.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+    loser.start();
+    try {
+      Awaitility.await()
+          .atMost(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+          .until(() -> loser.getState() == Thread.State.BLOCKED || 
loserTask.isDone());
+    } finally {
+      releaseFilter.countDown();
+    }
+
+    winner.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    loser.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    assertThat(winner.isAlive()).isFalse();
+    assertThat(loser.isAlive()).isFalse();
+    assertThat(winnerTask.get()).isNull();
+    assertThat(loserTask.get()).isNull();
+  }
+
+  private InstrumentedScan newScan(
+      Table table, CountDownLatch enteredResetTasks, CountDownLatch 
releaseFilter) {
+    SparkReadConf readConf = new SparkReadConf(spark, table, 
ImmutableMap.of());
+    Schema schema = table.schema();
+    Snapshot snapshot = table.currentSnapshot();
+    BatchScan batchScan =
+        table
+            .newBatchScan()
+            .useSnapshot(snapshot.snapshotId())
+            .ignoreResiduals()
+            .caseSensitive(readConf.caseSensitive())
+            .filter(Expressions.alwaysTrue())
+            .project(schema);
+    return new InstrumentedScan(
+        spark,
+        table,
+        batchScan,
+        snapshot,
+        readConf,
+        schema,
+        Collections.emptyList(),
+        () -> null,
+        enteredResetTasks,
+        releaseFilter);
+  }
+
+  private static class InstrumentedScan extends SparkCopyOnWriteScan {
+    private final CountDownLatch enteredResetTasks;
+    private final CountDownLatch releaseFilter;
+
+    InstrumentedScan(
+        SparkSession spark,
+        Table table,
+        BatchScan scan,
+        Snapshot snapshot,
+        SparkReadConf readConf,
+        Schema schema,
+        List<Expression> filters,
+        Supplier<ScanReport> scanReportSupplier,
+        CountDownLatch enteredResetTasks,
+        CountDownLatch releaseFilter) {
+      super(spark, table, scan, snapshot, readConf, schema, filters, 
scanReportSupplier);
+      this.enteredResetTasks = enteredResetTasks;
+      this.releaseFilter = releaseFilter;
+    }
+
+    @Override
+    protected void resetTasks(List<FileScanTask> filteredTasks) {
+      enteredResetTasks.countDown();
+      try {
+        assertThat(releaseFilter.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new AssertionError(e);
+      }
+      super.resetTasks(filteredTasks);
+    }
+  }
+}
diff --git 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
index a361a7f1ba..69fc4e2425 100644
--- 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
+++ 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkBatchQueryScan.java
@@ -124,7 +124,7 @@ class SparkBatchQueryScan extends 
SparkPartitioningAwareScan<PartitionScanTask>
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     Expression runtimeFilterExpr = convertRuntimeFilters(predicates);
 
     if (runtimeFilterExpr != Expressions.alwaysTrue()) {
diff --git 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
index f957b97d60..7930068189 100644
--- 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
+++ 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
@@ -103,7 +103,7 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     Preconditions.checkState(
         Objects.equals(snapshotId(), currentSnapshotId()),
         "Runtime file filtering is not possible: the table has been 
concurrently modified. "
diff --git 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
index 4d9fb7556b..7dad233895 100644
--- 
a/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
+++ 
b/spark/v4.0/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
@@ -243,7 +243,7 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
 
   // only task groups can be reset while resetting tasks
   // the set of scanned specs and grouping key type must never change
-  protected void resetTasks(List<T> filteredTasks) {
+  protected synchronized void resetTasks(List<T> filteredTasks) {
     this.taskGroups = null;
     this.tasks = filteredTasks;
   }
diff --git 
a/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
 
b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
new file mode 100644
index 0000000000..0182cb3c01
--- /dev/null
+++ 
b/spark/v4.0/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
@@ -0,0 +1,194 @@
+/*
+ * 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 org.apache.iceberg.spark.source;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import org.apache.iceberg.BatchScan;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.metrics.ScanReport;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.spark.SparkReadConf;
+import org.apache.iceberg.spark.TestBaseWithCatalog;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.FieldReference;
+import org.apache.spark.sql.connector.expressions.LiteralValue;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.filter.Predicate;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.unsafe.types.UTF8String;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.Timeout;
+
+public class TestSparkCopyOnWriteScanConcurrency extends TestBaseWithCatalog {
+
+  private static final long TIMEOUT_SECONDS = 10;
+
+  @AfterEach
+  public void removeTables() {
+    sql("DROP TABLE IF EXISTS %s", tableName);
+  }
+
+  @TestTemplate
+  @Timeout(value = 60, unit = TimeUnit.SECONDS)
+  public void concurrentRuntimeFilteringNarrowsSharedScan() throws Exception {
+    sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+    sql("INSERT INTO %s VALUES (2, 'b')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    CountDownLatch enteredResetTasks = new CountDownLatch(1);
+    CountDownLatch releaseFilter = new CountDownLatch(1);
+    InstrumentedScan scan = newScan(table, enteredResetTasks, releaseFilter);
+
+    List<String> locations =
+        scan.tasks().stream().map(task -> 
task.file().location()).collect(Collectors.toList());
+    assertThat(locations).hasSize(2);
+    assertThat(
+            scan.taskGroups().stream()
+                .flatMap(group -> group.tasks().stream())
+                .map(task -> task.file().location())
+                .collect(Collectors.toList()))
+        .containsExactlyInAnyOrderElementsOf(locations);
+    String location = locations.get(0);
+    NamedReference fileRef = 
FieldReference.apply(MetadataColumns.FILE_PATH.name());
+    LiteralValue<UTF8String> literal =
+        new LiteralValue<>(UTF8String.fromString(location), 
DataTypes.StringType);
+    Predicate[] predicates = {
+      new Predicate(
+          "IN", new org.apache.spark.sql.connector.expressions.Expression[] 
{fileRef, literal})
+    };
+
+    FutureTask<Void> winnerTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              return null;
+            });
+    FutureTask<Void> loserTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              assertThat(scan.tasks())
+                  .extracting(task -> task.file().location())
+                  .containsExactly(location);
+              assertThat(
+                      scan.taskGroups().stream()
+                          .flatMap(group -> group.tasks().stream())
+                          .map(task -> task.file().location())
+                          .collect(Collectors.toList()))
+                  .containsExactly(location);
+              return null;
+            });
+    Thread winner = new Thread(winnerTask);
+    Thread loser = new Thread(loserTask);
+
+    winner.start();
+    assertThat(enteredResetTasks.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+    loser.start();
+    try {
+      Awaitility.await()
+          .atMost(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+          .until(() -> loser.getState() == Thread.State.BLOCKED || 
loserTask.isDone());
+    } finally {
+      releaseFilter.countDown();
+    }
+
+    winner.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    loser.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    assertThat(winner.isAlive()).isFalse();
+    assertThat(loser.isAlive()).isFalse();
+    assertThat(winnerTask.get()).isNull();
+    assertThat(loserTask.get()).isNull();
+  }
+
+  private InstrumentedScan newScan(
+      Table table, CountDownLatch enteredResetTasks, CountDownLatch 
releaseFilter) {
+    SparkReadConf readConf = new SparkReadConf(spark, table, 
ImmutableMap.of());
+    Schema schema = table.schema();
+    Snapshot snapshot = table.currentSnapshot();
+    BatchScan batchScan =
+        table
+            .newBatchScan()
+            .useSnapshot(snapshot.snapshotId())
+            .ignoreResiduals()
+            .caseSensitive(readConf.caseSensitive())
+            .filter(Expressions.alwaysTrue())
+            .project(schema);
+    return new InstrumentedScan(
+        spark,
+        table,
+        batchScan,
+        snapshot,
+        readConf,
+        schema,
+        Collections.emptyList(),
+        () -> null,
+        enteredResetTasks,
+        releaseFilter);
+  }
+
+  private static class InstrumentedScan extends SparkCopyOnWriteScan {
+    private final CountDownLatch enteredResetTasks;
+    private final CountDownLatch releaseFilter;
+
+    InstrumentedScan(
+        SparkSession spark,
+        Table table,
+        BatchScan scan,
+        Snapshot snapshot,
+        SparkReadConf readConf,
+        Schema schema,
+        List<Expression> filters,
+        Supplier<ScanReport> scanReportSupplier,
+        CountDownLatch enteredResetTasks,
+        CountDownLatch releaseFilter) {
+      super(spark, table, scan, snapshot, readConf, schema, filters, 
scanReportSupplier);
+      this.enteredResetTasks = enteredResetTasks;
+      this.releaseFilter = releaseFilter;
+    }
+
+    @Override
+    protected void resetTasks(List<FileScanTask> filteredTasks) {
+      enteredResetTasks.countDown();
+      try {
+        assertThat(releaseFilter.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new AssertionError(e);
+      }
+      super.resetTasks(filteredTasks);
+    }
+  }
+}
diff --git 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
index b8b3a0edea..5ce5fabc48 100644
--- 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
+++ 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
@@ -92,7 +92,7 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     for (Predicate predicate : predicates) {
       // Spark can only pass IN predicates at the moment
       if (isFilePathInPredicate(predicate)) {
diff --git 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
index fe5eeee8fb..646680be6d 100644
--- 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
+++ 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
@@ -244,7 +244,7 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
 
   // only task groups can be reset while resetting tasks
   // the set of scanned specs and grouping key type must never change
-  protected void resetTasks(List<T> filteredTasks) {
+  protected synchronized void resetTasks(List<T> filteredTasks) {
     this.taskGroups = null;
     this.tasks = filteredTasks;
   }
diff --git 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
index dfd38a1548..a1f19a4bdc 100644
--- 
a/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
+++ 
b/spark/v4.1/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
@@ -106,7 +106,7 @@ abstract class SparkRuntimeFilterableScan extends 
SparkPartitioningAwareScan<Par
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     Expression runtimeFilter = convertRuntimePredicates(predicates);
 
     if (runtimeFilter != Expressions.alwaysTrue()) {
diff --git 
a/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
 
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
new file mode 100644
index 0000000000..380461d295
--- /dev/null
+++ 
b/spark/v4.1/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
@@ -0,0 +1,205 @@
+/*
+ * 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 org.apache.iceberg.spark.source;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import org.apache.iceberg.BatchScan;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.metrics.ScanReport;
+import org.apache.iceberg.spark.SparkReadConf;
+import org.apache.iceberg.spark.TestBaseWithCatalog;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.FieldReference;
+import org.apache.spark.sql.connector.expressions.LiteralValue;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.filter.Predicate;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.unsafe.types.UTF8String;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.Timeout;
+
+public class TestSparkCopyOnWriteScanConcurrency extends TestBaseWithCatalog {
+
+  private static final long TIMEOUT_SECONDS = 10;
+
+  @AfterEach
+  public void removeTables() {
+    sql("DROP TABLE IF EXISTS %s", tableName);
+  }
+
+  @TestTemplate
+  @Timeout(value = 60, unit = TimeUnit.SECONDS)
+  public void concurrentRuntimeFilteringNarrowsSharedScan() throws Exception {
+    sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+    sql("INSERT INTO %s VALUES (2, 'b')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    CountDownLatch enteredResetTasks = new CountDownLatch(1);
+    CountDownLatch releaseFilter = new CountDownLatch(1);
+    InstrumentedScan scan = newScan(table, enteredResetTasks, releaseFilter);
+
+    List<String> locations =
+        scan.tasks().stream().map(task -> 
task.file().location()).collect(Collectors.toList());
+    assertThat(locations).hasSize(2);
+    assertThat(
+            scan.taskGroups().stream()
+                .flatMap(group -> group.tasks().stream())
+                .map(task -> task.file().location())
+                .collect(Collectors.toList()))
+        .containsExactlyInAnyOrderElementsOf(locations);
+    String location = locations.get(0);
+    NamedReference fileRef = 
FieldReference.apply(MetadataColumns.FILE_PATH.name());
+    LiteralValue<UTF8String> literal =
+        new LiteralValue<>(UTF8String.fromString(location), 
DataTypes.StringType);
+    Predicate[] predicates = {
+      new Predicate(
+          "IN", new org.apache.spark.sql.connector.expressions.Expression[] 
{fileRef, literal})
+    };
+
+    FutureTask<Void> winnerTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              return null;
+            });
+    FutureTask<Void> loserTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              assertThat(scan.tasks())
+                  .extracting(task -> task.file().location())
+                  .containsExactly(location);
+              assertThat(
+                      scan.taskGroups().stream()
+                          .flatMap(group -> group.tasks().stream())
+                          .map(task -> task.file().location())
+                          .collect(Collectors.toList()))
+                  .containsExactly(location);
+              return null;
+            });
+    Thread winner = new Thread(winnerTask);
+    Thread loser = new Thread(loserTask);
+
+    winner.start();
+    assertThat(enteredResetTasks.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+    loser.start();
+    try {
+      Awaitility.await()
+          .atMost(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+          .until(() -> loser.getState() == Thread.State.BLOCKED || 
loserTask.isDone());
+    } finally {
+      releaseFilter.countDown();
+    }
+
+    winner.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    loser.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    assertThat(winner.isAlive()).isFalse();
+    assertThat(loser.isAlive()).isFalse();
+    assertThat(winnerTask.get()).isNull();
+    assertThat(loserTask.get()).isNull();
+  }
+
+  private InstrumentedScan newScan(
+      Table table, CountDownLatch enteredResetTasks, CountDownLatch 
releaseFilter) {
+    SparkReadConf readConf = new SparkReadConf(spark, table);
+    Schema schema = table.schema();
+    Snapshot snapshot = table.currentSnapshot();
+    BatchScan batchScan =
+        table
+            .newBatchScan()
+            .useSnapshot(snapshot.snapshotId())
+            .ignoreResiduals()
+            .caseSensitive(readConf.caseSensitive())
+            .filter(Expressions.alwaysTrue())
+            .project(schema);
+    return new InstrumentedScan(
+        spark,
+        table,
+        schema,
+        snapshot,
+        batchScan,
+        readConf,
+        schema,
+        Collections.emptyList(),
+        () -> null,
+        enteredResetTasks,
+        releaseFilter);
+  }
+
+  private static class InstrumentedScan extends SparkCopyOnWriteScan {
+    private final CountDownLatch enteredResetTasks;
+    private final CountDownLatch releaseFilter;
+
+    InstrumentedScan(
+        SparkSession spark,
+        Table table,
+        Schema schema,
+        Snapshot snapshot,
+        BatchScan scan,
+        SparkReadConf readConf,
+        Schema projection,
+        List<Expression> filters,
+        Supplier<ScanReport> scanReportSupplier,
+        CountDownLatch enteredResetTasks,
+        CountDownLatch releaseFilter) {
+      super(
+          spark,
+          table,
+          schema,
+          snapshot,
+          null,
+          scan,
+          readConf,
+          projection,
+          filters,
+          scanReportSupplier);
+      this.enteredResetTasks = enteredResetTasks;
+      this.releaseFilter = releaseFilter;
+    }
+
+    @Override
+    protected void resetTasks(List<FileScanTask> filteredTasks) {
+      enteredResetTasks.countDown();
+      try {
+        assertThat(releaseFilter.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new AssertionError(e);
+      }
+      super.resetTasks(filteredTasks);
+    }
+  }
+}
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
index 528409f96b..7ebc013431 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkCopyOnWriteScan.java
@@ -103,7 +103,7 @@ class SparkCopyOnWriteScan extends 
SparkPartitioningAwareScan<FileScanTask>
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     for (Predicate predicate : predicates) {
       // Spark can only pass IN predicates at the moment
       if (isFilePathInPredicate(predicate)) {
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
index 124f00f0b9..d6a04cd884 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkPartitioningAwareScan.java
@@ -273,7 +273,7 @@ abstract class SparkPartitioningAwareScan<T extends 
PartitionScanTask> extends S
 
   // only task groups can be reset while resetting tasks
   // the set of scanned specs and grouping key type must never change
-  protected void resetTasks(List<T> filteredTasks) {
+  protected synchronized void resetTasks(List<T> filteredTasks) {
     this.taskGroups = null;
     this.tasks = filteredTasks;
   }
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
index 130e453196..25bca85263 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkRuntimeFilterableScan.java
@@ -117,7 +117,7 @@ abstract class SparkRuntimeFilterableScan extends 
SparkPartitioningAwareScan<Par
   }
 
   @Override
-  public void filter(Predicate[] predicates) {
+  public synchronized void filter(Predicate[] predicates) {
     Expression runtimeFilter = convertRuntimePredicates(predicates);
 
     if (runtimeFilter != Expressions.alwaysTrue()) {
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
new file mode 100644
index 0000000000..120985f154
--- /dev/null
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCopyOnWriteScanConcurrency.java
@@ -0,0 +1,206 @@
+/*
+ * 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 org.apache.iceberg.spark.source;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import org.apache.iceberg.BatchScan;
+import org.apache.iceberg.FileScanTask;
+import org.apache.iceberg.MetadataColumns;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.metrics.ScanReport;
+import org.apache.iceberg.spark.SparkReadConf;
+import org.apache.iceberg.spark.TestBaseWithCatalog;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.connector.expressions.FieldReference;
+import org.apache.spark.sql.connector.expressions.LiteralValue;
+import org.apache.spark.sql.connector.expressions.NamedReference;
+import org.apache.spark.sql.connector.expressions.filter.Predicate;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.unsafe.types.UTF8String;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.Timeout;
+
+public class TestSparkCopyOnWriteScanConcurrency extends TestBaseWithCatalog {
+
+  private static final long TIMEOUT_SECONDS = 10;
+
+  @AfterEach
+  public void removeTables() {
+    sql("DROP TABLE IF EXISTS %s", tableName);
+  }
+
+  @TestTemplate
+  @Timeout(value = 60, unit = TimeUnit.SECONDS)
+  public void concurrentRuntimeFilteringNarrowsSharedScan() throws Exception {
+    sql("CREATE TABLE %s (id BIGINT, data STRING) USING iceberg", tableName);
+    sql("INSERT INTO %s VALUES (1, 'a')", tableName);
+    sql("INSERT INTO %s VALUES (2, 'b')", tableName);
+
+    Table table = validationCatalog.loadTable(tableIdent);
+    CountDownLatch enteredResetTasks = new CountDownLatch(1);
+    CountDownLatch releaseFilter = new CountDownLatch(1);
+    InstrumentedScan scan = newScan(table, enteredResetTasks, releaseFilter);
+
+    List<String> locations =
+        scan.tasks().stream().map(task -> 
task.file().location()).collect(Collectors.toList());
+    assertThat(locations).hasSize(2);
+    assertThat(
+            scan.taskGroups().stream()
+                .flatMap(group -> group.tasks().stream())
+                .map(task -> task.file().location())
+                .collect(Collectors.toList()))
+        .containsExactlyInAnyOrderElementsOf(locations);
+    String location = locations.get(0);
+    NamedReference fileRef = 
FieldReference.apply(MetadataColumns.FILE_PATH.name());
+    LiteralValue<UTF8String> literal =
+        new LiteralValue<>(UTF8String.fromString(location), 
DataTypes.StringType);
+    Predicate[] predicates = {
+      new Predicate(
+          "IN", new org.apache.spark.sql.connector.expressions.Expression[] 
{fileRef, literal})
+    };
+
+    FutureTask<Void> winnerTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              return null;
+            });
+    FutureTask<Void> loserTask =
+        new FutureTask<>(
+            () -> {
+              scan.filter(predicates);
+              assertThat(scan.tasks())
+                  .extracting(task -> task.file().location())
+                  .containsExactly(location);
+              assertThat(
+                      scan.taskGroups().stream()
+                          .flatMap(group -> group.tasks().stream())
+                          .map(task -> task.file().location())
+                          .collect(Collectors.toList()))
+                  .containsExactly(location);
+              return null;
+            });
+    Thread winner = new Thread(winnerTask);
+    Thread loser = new Thread(loserTask);
+
+    winner.start();
+    assertThat(enteredResetTasks.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+    loser.start();
+    try {
+      Awaitility.await()
+          .atMost(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+          .until(() -> loser.getState() == Thread.State.BLOCKED || 
loserTask.isDone());
+    } finally {
+      releaseFilter.countDown();
+    }
+
+    winner.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    loser.join(TimeUnit.SECONDS.toMillis(TIMEOUT_SECONDS));
+    assertThat(winner.isAlive()).isFalse();
+    assertThat(loser.isAlive()).isFalse();
+    assertThat(winnerTask.get()).isNull();
+    assertThat(loserTask.get()).isNull();
+  }
+
+  private InstrumentedScan newScan(
+      Table table, CountDownLatch enteredResetTasks, CountDownLatch 
releaseFilter) {
+    SparkReadConf readConf = new SparkReadConf(spark, table);
+    Schema schema = table.schema();
+    Snapshot snapshot = table.currentSnapshot();
+    BatchScan batchScan =
+        table
+            .newBatchScan()
+            .useSnapshot(snapshot.snapshotId())
+            .ignoreResiduals()
+            .caseSensitive(readConf.caseSensitive())
+            .filter(Expressions.alwaysTrue())
+            .project(schema);
+    return new InstrumentedScan(
+        spark,
+        table,
+        schema,
+        snapshot,
+        batchScan,
+        readConf,
+        schema,
+        Collections.emptyList(),
+        () -> null,
+        enteredResetTasks,
+        releaseFilter);
+  }
+
+  private static class InstrumentedScan extends SparkCopyOnWriteScan {
+    private final CountDownLatch enteredResetTasks;
+    private final CountDownLatch releaseFilter;
+
+    InstrumentedScan(
+        SparkSession spark,
+        Table table,
+        Schema schema,
+        Snapshot snapshot,
+        BatchScan scan,
+        SparkReadConf readConf,
+        Schema projection,
+        List<Expression> filters,
+        Supplier<ScanReport> scanReportSupplier,
+        CountDownLatch enteredResetTasks,
+        CountDownLatch releaseFilter) {
+      super(
+          spark,
+          table,
+          schema,
+          snapshot,
+          null,
+          scan,
+          readConf,
+          projection,
+          filters,
+          Collections.emptyList(),
+          scanReportSupplier);
+      this.enteredResetTasks = enteredResetTasks;
+      this.releaseFilter = releaseFilter;
+    }
+
+    @Override
+    protected void resetTasks(List<FileScanTask> filteredTasks) {
+      enteredResetTasks.countDown();
+      try {
+        assertThat(releaseFilter.await(TIMEOUT_SECONDS, 
TimeUnit.SECONDS)).isTrue();
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new AssertionError(e);
+      }
+      super.resetTasks(filteredTasks);
+    }
+  }
+}

Reply via email to