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 cdeb84d341 Spark 4.2: Support iterative runtime filtering with
PartitionPredicate (#18600)
cdeb84d341 is described below
commit cdeb84d341631b0c89a4defc61f38eccf5768cea
Author: Xiduo You <[email protected]>
AuthorDate: Thu Oct 22 06:50:36 2026 +0800
Spark 4.2: Support iterative runtime filtering with PartitionPredicate
(#18600)
---
.../spark/source/SparkPartitioningAwareScan.java | 12 ++
.../spark/source/SparkRuntimeFilterableScan.java | 50 +++++
.../iceberg/spark/source/TestRuntimeFiltering.java | 232 ++++++++++++++++++++-
3 files changed, 291 insertions(+), 3 deletions(-)
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 d6a04cd884..f4b1c406d6 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
@@ -306,6 +306,18 @@ abstract class SparkPartitioningAwareScan<T extends
PartitionScanTask> extends S
return evaluator.eval(task.partition());
}
+ /**
+ * Returns the planned tasks that match all partition predicates in the scan.
+ *
+ * <p>Tasks from specs where a predicate cannot be evaluated are kept.
+ */
+ protected synchronized List<T> tasksMatchingPartitionPredicates() {
+ Map<Integer, PartitionPredicateEvaluator> evaluatorsBySpecId =
Maps.newHashMap();
+ return tasks().stream()
+ .filter(task -> matchesPartitionPredicates(task, evaluatorsBySpecId))
+ .collect(Collectors.toList());
+ }
+
private static List<PartitionField> activePartitionFields(PartitionSpec
spec) {
return spec.fields().stream()
.filter(field -> !field.transform().isVoid())
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 25bca85263..44293cbb85 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
@@ -65,6 +65,7 @@ abstract class SparkRuntimeFilterableScan extends
SparkPartitioningAwareScan<Par
private static final Logger LOG =
LoggerFactory.getLogger(SparkRuntimeFilterableScan.class);
private final List<Expression> runtimeFilters;
+ private final List<Predicate> pushedPredicates;
protected SparkRuntimeFilterableScan(
SparkSession spark,
@@ -87,6 +88,7 @@ abstract class SparkRuntimeFilterableScan extends
SparkPartitioningAwareScan<Par
partitionPredicates,
scanReportSupplier);
this.runtimeFilters = Lists.newArrayList();
+ this.pushedPredicates = Lists.newArrayList();
}
@Override
@@ -116,8 +118,55 @@ abstract class SparkRuntimeFilterableScan extends
SparkPartitioningAwareScan<Par
.toArray(NamedReference[]::new);
}
+ @Override
+ public boolean supportsIterativePushdown() {
+ return true;
+ }
+
+ @Override
+ public synchronized Predicate[] pushedPredicates() {
+ return pushedPredicates.toArray(new Predicate[0]);
+ }
+
@Override
public synchronized void filter(Predicate[] predicates) {
+ List<PartitionPredicate> partitionPredicates = Lists.newArrayList();
+ List<Predicate> runtimePredicates = Lists.newArrayList();
+
+ for (Predicate predicate : predicates) {
+ if (predicate instanceof PartitionPredicate partitionPredicate) {
+ partitionPredicates.add(partitionPredicate);
+ } else {
+ runtimePredicates.add(predicate);
+ }
+ }
+
+ if (!runtimePredicates.isEmpty()) {
+ applyRuntimeFilter(runtimePredicates.toArray(new Predicate[0]));
+ }
+
+ if (!partitionPredicates.isEmpty()) {
+ applyPartitionPredicates(partitionPredicates);
+ pushedPredicates.addAll(partitionPredicates);
+ }
+ }
+
+ private void applyPartitionPredicates(List<PartitionPredicate>
additionalPredicates) {
+ partitionPredicates().addAll(additionalPredicates);
+
+ List<PartitionScanTask> filteredTasks = tasksMatchingPartitionPredicates();
+ if (filteredTasks.size() < tasks().size()) {
+ LOG.info(
+ "{} of {} task(s) for table {} matched {} opaque runtime partition
predicate(s)",
+ filteredTasks.size(),
+ tasks().size(),
+ table().name(),
+ additionalPredicates.size());
+ resetTasks(filteredTasks);
+ }
+ }
+
+ private void applyRuntimeFilter(Predicate[] predicates) {
Expression runtimeFilter = convertRuntimePredicates(predicates);
if (runtimeFilter != Expressions.alwaysTrue()) {
@@ -194,6 +243,7 @@ abstract class SparkRuntimeFilterableScan extends
SparkPartitioningAwareScan<Par
try {
Binder.bind(projection().asStruct(), expr, caseSensitive());
filter = Expressions.and(filter, expr);
+ pushedPredicates.add(predicate);
} catch (ValidationException e) {
LOG.warn("Failed to bind {} to expected schema, skipping runtime
filter", expr, e);
}
diff --git
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java
index e7346e270f..0623d63e64 100644
---
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java
+++
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestRuntimeFiltering.java
@@ -27,12 +27,15 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.iceberg.FileScanTask;
import org.apache.iceberg.Parameter;
import org.apache.iceberg.ParameterizedTestExtension;
import org.apache.iceberg.Parameters;
+import org.apache.iceberg.PartitionField;
+import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.PlanningMode;
import org.apache.iceberg.Table;
import org.apache.iceberg.expressions.Expression;
@@ -46,6 +49,7 @@ import org.apache.iceberg.spark.TestBaseWithCatalog;
import org.apache.spark.sql.Dataset;
import org.apache.spark.sql.Row;
import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.types.DataTypes;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -458,6 +462,190 @@ public class TestRuntimeFiltering extends
TestBaseWithCatalog {
sql(query));
}
+ @TestTemplate
+ public void scalarSubqueryWithUnsupportedPartitionPredicate() {
+ sql("CREATE TABLE %s (id INT, part STRING) USING iceberg PARTITIONED BY
(part)", tableName);
+ configurePlanningMode(planningMode);
+ sql("INSERT INTO %s VALUES (1, 'abc'), (2, 'abx'), (3, 'def'), (4,
'xyz')", tableName);
+
+ sql("CREATE TABLE dim (pattern STRING) USING parquet");
+ sql("INSERT INTO dim VALUES ('^ab')");
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE part RLIKE (SELECT max(pattern) FROM dim)
ORDER BY id",
+ tableName);
+
+ deleteNotMatchingFiles(Expressions.startsWith("part", "ab"));
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE part RLIKE '^ab' ORDER BY id", tableName),
+ sql(query));
+ }
+
+ @TestTemplate
+ public void scalarSubqueryWithUdfPartitionPredicate() {
+ sql("CREATE TABLE %s (id INT, part STRING) USING iceberg PARTITIONED BY
(part)", tableName);
+ configurePlanningMode(planningMode);
+ sql("INSERT INTO %s VALUES (1, 'a'), (2, 'A'), (3, 'b'), (4, 'B')",
tableName);
+
+ sql("CREATE TABLE dim (val STRING) USING parquet");
+ sql("INSERT INTO dim VALUES ('A')");
+
+ spark
+ .udf()
+ .register(
+ "my_upper_runtime",
+ (String value) -> value == null ? null :
value.toUpperCase(Locale.ROOT),
+ DataTypes.StringType);
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE my_upper_runtime(part) = (SELECT max(val)
FROM dim) ORDER BY id",
+ tableName);
+
+ deleteNotMatchingFiles(
+ Expressions.or(Expressions.equal("part", "a"),
Expressions.equal("part", "A")));
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE my_upper_runtime(part) = 'A' ORDER BY id",
tableName),
+ sql(query));
+ }
+
+ @TestTemplate
+ public void scalarSubqueryOnNonFirstPartitionColumn() {
+ sql(
+ "CREATE TABLE %s (id INT, p1 INT, p2 STRING) USING iceberg PARTITIONED
BY (p1, p2)",
+ tableName);
+ configurePlanningMode(planningMode);
+ sql(
+ "INSERT INTO %s VALUES (1, 1, 'abc'), (2, 1, 'def'), (3, 2, 'abx'),
(4, 2, 'xyz')",
+ tableName);
+
+ sql("CREATE TABLE dim (pattern STRING) USING parquet");
+ sql("INSERT INTO dim VALUES ('^ab')");
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE p2 RLIKE (SELECT max(pattern) FROM dim)
ORDER BY id",
+ tableName);
+
+ deleteNotMatchingFiles(Expressions.startsWith("p2", "ab"));
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE p2 RLIKE '^ab' ORDER BY id", tableName),
+ sql(query));
+ }
+
+ @TestTemplate
+ public void scalarSubqueryWithSupportedPartitionPredicate() {
+ sql("CREATE TABLE %s (id INT, part STRING) USING iceberg PARTITIONED BY
(part)", tableName);
+ configurePlanningMode(planningMode);
+ sql("INSERT INTO %s VALUES (1, 'abc'), (2, 'def')", tableName);
+
+ sql("CREATE TABLE dim (val STRING) USING parquet");
+ sql("INSERT INTO dim VALUES ('abc')");
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE part = (SELECT max(val) FROM dim) ORDER BY
id", tableName);
+
+ deleteNotMatchingFiles(Expressions.equal("part", "abc"));
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE part = 'abc' ORDER BY id", tableName),
+ sql(query));
+ }
+
+ @TestTemplate
+ public void scalarSubqueryWithMultiplePartitionSpecs() {
+ sql("CREATE TABLE %s (id INT, part STRING) USING iceberg PARTITIONED BY
(part)", tableName);
+ configurePlanningMode(planningMode);
+ sql("INSERT INTO %s VALUES (1, 'abc'), (2, 'def')", tableName);
+
+
validationCatalog.loadTable(tableIdent).updateSpec().addField("id").commit();
+ sql("REFRESH TABLE %s", tableName);
+
+ sql("INSERT INTO %s VALUES (3, 'abx'), (4, 'xyz')", tableName);
+
+ sql("CREATE TABLE dim (pattern STRING) USING parquet");
+ sql("INSERT INTO dim VALUES ('^ab')");
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE part RLIKE (SELECT max(pattern) FROM dim)
ORDER BY id",
+ tableName);
+
+ deleteNotMatchingFiles(Expressions.startsWith("part", "ab"));
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE part RLIKE '^ab' ORDER BY id", tableName),
+ sql(query));
+ }
+
+ @TestTemplate
+ public void scalarSubqueryKeepsTasksWithNonEvaluablePartitionSpec() {
+ sql("CREATE TABLE %s (id INT, data STRING) USING iceberg PARTITIONED BY
(data)", tableName);
+ configurePlanningMode(planningMode);
+ sql("INSERT INTO %s VALUES (1, 'x'), (2, 'y'), (9, 'x')", tableName);
+
+ validationCatalog
+ .loadTable(tableIdent)
+ .updateSpec()
+ .addField("id")
+ .removeField("data")
+ .commit();
+ sql("REFRESH TABLE %s", tableName);
+
+ sql("INSERT INTO %s VALUES (1, 'z'), (4, 'z')", tableName);
+
+ sql("CREATE TABLE dim (val INT) USING parquet");
+ sql("INSERT INTO dim VALUES (1)");
+
+ deletePartitionFile(validationCatalog.loadTable(tableIdent), "id", 1);
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE coalesce(id, 0) > (SELECT max(val) FROM
dim) ORDER BY id",
+ tableName);
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE id > 1 ORDER BY id", tableName),
+ sql(query));
+ }
+
+ @TestTemplate
+ public void scalarSubqueriesWithAcceptedAndRejectedPredicates() {
+ sql(
+ "CREATE TABLE %s (id INT, p1 STRING, p2 INT) USING iceberg PARTITIONED
BY (p1, p2)",
+ tableName);
+ configurePlanningMode(planningMode);
+ sql("INSERT INTO %s VALUES (1, 'a', 1), (2, 'a', 2), (3, 'b', 1), (4, 'b',
2)", tableName);
+
+ sql("CREATE TABLE dim (v1 STRING, v2 INT) USING parquet");
+ sql("INSERT INTO dim VALUES ('a', 1)");
+
+ String query =
+ String.format(
+ "SELECT * FROM %s WHERE p1 = (SELECT max(v1) FROM dim) "
+ + "AND coalesce(p2, 0) > (SELECT max(v2) FROM dim) ORDER BY
id",
+ tableName);
+
+ deleteNotMatchingFiles(
+ Expressions.and(Expressions.equal("p1", "a"), Expressions.equal("p2",
2)));
+
+ assertEquals(
+ "Should have expected rows",
+ sql("SELECT * FROM %s WHERE p1 = 'a' AND p2 = 2 ORDER BY id",
tableName),
+ sql(query));
+ }
+
private void assertQueryContainsRuntimeFilter(String query) {
assertQueryContainsRuntimeFilters(query, 1, "Query should have 1 runtime
filter");
}
@@ -477,6 +665,18 @@ public class TestRuntimeFiltering extends
TestBaseWithCatalog {
// delete files that don't match the filter to ensure dynamic filtering
works and only required
// files are read
private void deleteNotMatchingFiles(Expression filter, int
expectedDeletedFileCount) {
+ assertThat(deleteFilesNotMatching(filter))
+ .as("Deleted unexpected number of files")
+ .hasSize(expectedDeletedFileCount);
+ }
+
+ private void deleteNotMatchingFiles(Expression filter) {
+ assertThat(deleteFilesNotMatching(filter))
+ .as("Should delete files to verify runtime filtering")
+ .isNotEmpty();
+ }
+
+ private Set<String> deleteFilesNotMatching(Expression filter) {
Table table = validationCatalog.loadTable(tableIdent);
FileIO io = table.io();
@@ -503,8 +703,34 @@ public class TestRuntimeFiltering extends
TestBaseWithCatalog {
throw new UncheckedIOException(e);
}
- assertThat(deletedFileLocations)
- .as("Deleted unexpected number of files")
- .hasSize(expectedDeletedFileCount);
+ return deletedFileLocations;
+ }
+
+ private void deletePartitionFile(Table table, String fieldName, int
partitionValue) {
+ PartitionSpec spec = table.spec();
+ int position = partitionPosition(spec, fieldName);
+ FileIO io = table.io();
+
+ try (CloseableIterable<FileScanTask> tasks = table.newScan().planFiles()) {
+ for (FileScanTask task : tasks) {
+ if (task.spec().specId() == spec.specId()
+ && partitionValue == task.partition().get(position,
Integer.class)) {
+ io.deleteFile(task.file().location());
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private static int partitionPosition(PartitionSpec spec, String fieldName) {
+ for (int pos = 0; pos < spec.fields().size(); pos++) {
+ PartitionField field = spec.fields().get(pos);
+ if (field.name().equals(fieldName) && !field.transform().isVoid()) {
+ return pos;
+ }
+ }
+
+ throw new IllegalArgumentException("Cannot find partition field: " +
fieldName);
}
}