This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 9636197fdb [core] Support partition pruning for AndPartitionPredicate
in format table scan (#8353)
9636197fdb is described below
commit 9636197fdb8a2077f58dbff3abae137a82c785b4
Author: Zouxxyy <[email protected]>
AuthorDate: Thu Jun 25 20:45:26 2026 +0800
[core] Support partition pruning for AndPartitionPredicate in format table
scan (#8353)
When a format table scan receives an `AndPartitionPredicate` (e.g. from
combining a static
pushed-down partition filter with a runtime/DPP filter), the scan-path
prefix optimization
and per-directory pruning were skipped because only
`DefaultPartitionPredicate` was handled.
This caused a full listing of all partition directories from the table
root — expensive on
object stores like OSS/S3.
This PR adds `extractPartitionPredicate()` which recursively unwraps
`AndPartitionPredicate`
into a single `Predicate` for pruning. Children that cannot be expressed
as a `Predicate`
(e.g. `MultiplePartitionPredicate`) are skipped; correctness is still
guaranteed by the
in-memory `partitionFilter.test()` in `plan()`.
Also replaces `exists()` + `getFileStatus()` with a single
`getFileStatus()` catching
`FileNotFoundException` to save one RPC per directory on object stores.
---
.../paimon/partition/PartitionPredicate.java | 4 +
.../paimon/table/format/FormatTableScan.java | 61 +++++++++--
.../apache/paimon/utils/PartitionPathUtils.java | 42 ++++----
.../paimon/table/format/FormatTableScanTest.java | 112 ++++++++++++++++++---
.../paimon/spark/sql/FormatTableTestBase.scala | 40 ++++++++
5 files changed, 213 insertions(+), 46 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionPredicate.java
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionPredicate.java
index 2820b52e9b..3a594aa409 100644
---
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionPredicate.java
+++
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionPredicate.java
@@ -325,6 +325,10 @@ public interface PartitionPredicate extends Serializable {
this.predicates = Collections.unmodifiableList(new
ArrayList<>(predicates));
}
+ public List<PartitionPredicate> predicates() {
+ return predicates;
+ }
+
@Override
public boolean test(BinaryRow partition) {
for (PartitionPredicate predicate : predicates) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
index 0b63e71318..9dca62508f 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
@@ -33,6 +33,7 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.manifest.PartitionEntry;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.partition.PartitionPredicate.AndPartitionPredicate;
import
org.apache.paimon.partition.PartitionPredicate.DefaultPartitionPredicate;
import
org.apache.paimon.partition.PartitionPredicate.MultiplePartitionPredicate;
import org.apache.paimon.predicate.Equal;
@@ -54,6 +55,9 @@ import org.apache.paimon.utils.InternalRowPartitionComputer;
import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.PartitionPathUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import javax.annotation.Nullable;
import java.io.IOException;
@@ -76,6 +80,8 @@ import static
org.apache.paimon.utils.PartitionPathUtils.searchPartSpecAndPaths;
/** {@link TableScan} for {@link FormatTable}. */
public class FormatTableScan implements InnerTableScan {
+ private static final Logger LOG =
LoggerFactory.getLogger(FormatTableScan.class);
+
private final FormatTable table;
private final CoreOptions coreOptions;
@Nullable private PartitionPredicate partitionFilter;
@@ -174,6 +180,10 @@ public class FormatTableScan implements InnerTableScan {
}
List<Pair<LinkedHashMap<String, String>, Path>> findPartitions() {
+ LOG.debug(
+ "Find partitions for format table {}, partition filter: {}",
+ table.name(),
+ partitionFilter);
boolean onlyValueInPath =
coreOptions.formatTablePartitionOnlyValueInPath();
if (partitionFilter instanceof MultiplePartitionPredicate) {
// generate partitions directly
@@ -190,17 +200,22 @@ public class FormatTableScan implements InnerTableScan {
// This will prune partition directories early during traversal,
// which is especially important for cloud storage like OSS/S3
Map<String, Predicate> partitionPredicates = new HashMap<>();
- if (partitionFilter instanceof DefaultPartitionPredicate) {
- Predicate predicate = ((DefaultPartitionPredicate)
partitionFilter).predicate();
+ Optional<Predicate> predicate =
extractPartitionPredicate(partitionFilter);
+ LOG.debug(
+ "Extracted predicate for format table {} partition
pruning: {}",
+ table.name(),
+ predicate.orElse(null));
+ if (predicate.isPresent()) {
partitionPredicates =
-
PredicateUtils.splitPartitionPredicate(table.partitionType(), predicate);
+ PredicateUtils.splitPartitionPredicate(
+ table.partitionType(), predicate.get());
}
Pair<Path, Integer> scanPathAndLevel =
computeScanPathAndLevel(
new Path(table.location()),
table.partitionKeys(),
- partitionFilter,
+ predicate,
table.partitionType(),
onlyValueInPath);
return searchPartSpecAndPaths(
@@ -241,22 +256,48 @@ public class FormatTableScan implements InnerTableScan {
return result;
}
+ /**
+ * Extracts the underlying {@link Predicate} used for partition-directory
pruning from a {@link
+ * PartitionPredicate}. Unlike data-table scans, which prune purely via
{@link
+ * PartitionPredicate#test} on partitions read from the manifest, a format
table has no manifest
+ * and must derive a {@link Predicate} to compute the scan-path prefix and
per-directory filters
+ * while listing. {@link AndPartitionPredicate} is unwrapped recursively.
Returns empty when the
+ * predicate cannot be expressed as a single {@link Predicate} (e.g. {@link
+ * MultiplePartitionPredicate}), in which case the caller falls back to
listing without pruning.
+ */
+ static Optional<Predicate> extractPartitionPredicate(
+ @Nullable PartitionPredicate partitionFilter) {
+ if (partitionFilter instanceof DefaultPartitionPredicate) {
+ return Optional.of(((DefaultPartitionPredicate)
partitionFilter).predicate());
+ } else if (partitionFilter instanceof AndPartitionPredicate) {
+ List<Predicate> predicates = new ArrayList<>();
+ for (PartitionPredicate child :
+ ((AndPartitionPredicate) partitionFilter).predicates()) {
+ Optional<Predicate> childPredicate =
extractPartitionPredicate(child);
+ childPredicate.ifPresent(predicates::add);
+ // Skip children that can't be expressed as Predicate (e.g.
Multiple);
+ // they are still applied by partitionFilter.test() in plan().
+ }
+ return predicates.isEmpty()
+ ? Optional.empty()
+ : Optional.of(PredicateBuilder.and(predicates));
+ }
+ return Optional.empty();
+ }
+
protected static Pair<Path, Integer> computeScanPathAndLevel(
Path tableLocation,
List<String> partitionKeys,
- PartitionPredicate partitionFilter,
+ Optional<Predicate> predicate,
RowType partitionType,
boolean onlyValueInPath) {
Path scanPath = tableLocation;
int level = partitionKeys.size();
if (!partitionKeys.isEmpty()) {
- // Try to optimize for equality partition filters
- if (partitionFilter instanceof DefaultPartitionPredicate) {
+ if (predicate.isPresent()) {
Map<String, String> equalityPrefix =
extractLeadingEqualityPartitionSpecWhenOnlyAnd(
- partitionKeys,
- ((DefaultPartitionPredicate)
partitionFilter).predicate(),
- partitionType);
+ partitionKeys, predicate.get(), partitionType);
if (!equalityPrefix.isEmpty()) {
// Use optimized scan for specific partition path
String partitionPath =
diff --git
a/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
b/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
index c20abf7f34..fc2fe23e05 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
@@ -28,6 +28,7 @@ import org.apache.paimon.types.RowType;
import javax.annotation.Nullable;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.BitSet;
@@ -345,28 +346,25 @@ public class PartitionPathUtils {
ArrayList<FileStatus> result = new ArrayList<>();
try {
- if (fileIO.exists(path)) {
- // ignore hidden file
- FileStatus fileStatus = fileIO.getFileStatus(path);
- // Calculate the starting offset when we begin from a prefix
path
- // For example, if partitionKeys = [ds, hr] and expectLevel =
1 (only hr remaining),
- // then levelOffset = 2 - 1 = 1, so we access partitionKeys[1]
for level 0
- int levelOffset = partitionKeys.size() - expectLevel;
- listStatusRecursively(
- fileIO,
- fileStatus,
- 0,
- expectLevel,
- result,
- partitionKeys,
- onlyValueInPath,
- partitionFilter,
- partitionType,
- defaultPartValue,
- levelOffset);
- } else {
- return new FileStatus[0];
- }
+ FileStatus fileStatus = fileIO.getFileStatus(path);
+ // Calculate the starting offset when we begin from a prefix path
+ // For example, if partitionKeys = [ds, hr] and expectLevel = 1
(only hr remaining),
+ // then levelOffset = 2 - 1 = 1, so we access partitionKeys[1] for
level 0
+ int levelOffset = partitionKeys.size() - expectLevel;
+ listStatusRecursively(
+ fileIO,
+ fileStatus,
+ 0,
+ expectLevel,
+ result,
+ partitionKeys,
+ onlyValueInPath,
+ partitionFilter,
+ partitionType,
+ defaultPartValue,
+ levelOffset);
+ } catch (FileNotFoundException e) {
+ return new FileStatus[0];
} catch (IOException e) {
throw new RuntimeException("Failed to list files in " + path, e);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
index 936b3d6bd1..0584577a1a 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
@@ -50,6 +50,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import static
org.apache.paimon.CoreOptions.FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH;
@@ -123,7 +124,11 @@ public class FormatTableScanTest {
Pair<Path, Integer> result =
FormatTableScan.computeScanPathAndLevel(
- defaultTableLocation, partitionKeys, partitionFilter,
partitionType, false);
+ defaultTableLocation,
+ partitionKeys,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
+ partitionType,
+ false);
assertThat(result.getLeft()).isEqualTo(defaultTableLocation);
assertThat(result.getRight()).isEqualTo(0);
@@ -150,7 +155,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
defaultTableLocation,
partitionType.getFieldNames(),
- null,
+ Optional.empty(),
partitionType,
false);
@@ -169,7 +174,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -210,7 +215,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
String partitionPath = enablePartitionValueOnly ? "2023/12" :
"year=2023/month=12";
@@ -253,7 +258,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
datePartitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
datePartitionType,
enablePartitionValueOnly);
String partitionPath = enablePartitionValueOnly ? "2026-05-01" :
"dt=2026-05-01";
@@ -275,7 +280,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -317,7 +322,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -352,7 +357,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -392,7 +397,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -415,7 +420,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -448,7 +453,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -482,7 +487,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -516,7 +521,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -549,7 +554,7 @@ public class FormatTableScanTest {
FormatTableScan.computeScanPathAndLevel(
tableLocation,
partitionKeys,
- partitionFilter,
+
FormatTableScan.extractPartitionPredicate(partitionFilter),
partitionType,
enablePartitionValueOnly);
@@ -934,4 +939,83 @@ public class FormatTableScanTest {
.collect(java.util.stream.Collectors.toList());
assertEquals(Arrays.asList("4", "5", "6", "7", "8", "9"), month);
}
+
+ @TestTemplate
+ void testFindPartitionsWithAndPartitionPredicate() throws IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+ LocalFileIO setupFileIO = LocalFileIO.create();
+
+ // Create partition directories for years 2022 to 2026, 12 months each
(60 partitions)
+ for (int year = 2022; year <= 2026; year++) {
+ String partPath = enablePartitionValueOnly ? String.valueOf(year)
: "year=" + year;
+ for (int month = 1; month <= 12; month++) {
+ String monthPart =
+ enablePartitionValueOnly
+ ? partPath + "/" + month
+ : partPath + "/month=" + month;
+ setupFileIO.mkdirs(new Path(tableLocation, monthPart));
+ }
+ }
+
+ AtomicInteger listCount = new AtomicInteger(0);
+ LocalFileIO localFileIO =
+ new LocalFileIO() {
+ @Override
+ public FileStatus[] listStatus(Path path) throws
IOException {
+ listCount.getAndIncrement();
+ return super.listStatus(path);
+ }
+ };
+
+ RowType rowType =
+ RowType.builder()
+ .field("year", DataTypes.INT())
+ .field("month", DataTypes.INT())
+ .field("a", DataTypes.INT())
+ .build();
+
+ FormatTable formatTable =
+ FormatTable.builder()
+ .fileIO(localFileIO)
+ .identifier(Identifier.create("test_db", "test_table"))
+ .rowType(rowType)
+ .partitionKeys(Arrays.asList("year", "month"))
+ .location(tableLocation.toString())
+ .format(FormatTable.Format.CSV)
+ .options(
+ Collections.singletonMap(
+
FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH.key(),
+
String.valueOf(enablePartitionValueOnly)))
+ .build();
+
+ // Simulate a static pushdown (year = 2024) combined with a
runtime/DPP partition filter
+ // (month > 3 AND month < 10): each is a DefaultPartitionPredicate and
the scan combines
+ // them via PartitionPredicate.and, exactly like BaseScan / runtime
filtering does. The
+ // combined predicate must still prune partition directories;
otherwise it falls back to a
+ // full table-root listing.
+ PredicateBuilder builder = new
PredicateBuilder(formatTable.partitionType());
+ PartitionPredicate yearFilter =
+ PartitionPredicate.fromPredicate(
+ formatTable.partitionType(), builder.equal(0, 2024));
+ PartitionPredicate monthFilter =
+ PartitionPredicate.fromPredicate(
+ formatTable.partitionType(),
+ PredicateBuilder.and(builder.greaterThan(1, 3),
builder.lessThan(1, 10)));
+ PartitionPredicate combined =
+ PartitionPredicate.and(Arrays.asList(yearFilter, monthFilter));
+
+ FormatTableScan scan = new FormatTableScan(formatTable, combined,
null);
+ List<Pair<LinkedHashMap<String, String>, Path>> result =
scan.findPartitions();
+
+ // Should prune to year=2024 and list its months only once (months
4-9).
+ assertEquals(6, result.size());
+ assertEquals(1, listCount.get());
+ List<String> months =
+ result.stream()
+ .map(pair -> pair.getKey().get("month"))
+ .sorted()
+ .distinct()
+ .collect(java.util.stream.Collectors.toList());
+ assertEquals(Arrays.asList("4", "5", "6", "7", "8", "9"), months);
+ }
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
index f32447cf43..4d1d49a799 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
@@ -440,6 +440,46 @@ abstract class FormatTableTestBase extends
PaimonHiveTestBase with AdaptiveSpark
}
}
+ test("Paimon format table: runtime filter combined with pushed-down
partition filter") {
+ withTable("dwd_fact", "dim_date") {
+ sql("""
+ |CREATE TABLE dwd_fact (id INT, amount DOUBLE, dt STRING, hour
STRING)
+ |USING PARQUET
+ |TBLPROPERTIES ('format-table.implementation'='paimon')
+ |PARTITIONED BY (dt, hour)
+ |""".stripMargin)
+ sql("""
+ |CREATE TABLE dim_date (dt STRING, name STRING)
+ |USING PARQUET
+ |TBLPROPERTIES ('format-table.implementation'='paimon')
+ |""".stripMargin)
+
+ sql("""
+ |INSERT INTO dwd_fact VALUES
+ |(1, 10.0, '20260622', '00'),
+ |(2, 20.0, '20260622', '01'),
+ |(3, 30.0, '20260621', '00'),
+ |(4, 40.0, '20260620', '23')
+ |""".stripMargin)
+ sql("INSERT INTO dim_date VALUES ('20260622', 'today')")
+
+ val df = sql("""
+ |SELECT f.id, f.dt, f.hour, d.name
+ |FROM dwd_fact f
+ |JOIN dim_date d ON f.dt = d.dt
+ |WHERE f.dt >= '20260620'
+ |ORDER BY f.id
+ |""".stripMargin)
+
+ checkAnswer(df, Seq(Row(1, "20260622", "00", "today"), Row(2,
"20260622", "01", "today")))
+
+ // Static pushdown (dt >= '20260620') alone keeps all 4 partitions; the
runtime filter on dt
+ // prunes the scan down to the two partitions of dt='20260622'.
+ val filteredSplits =
collectFilteredInputSplits(df.queryExecution.executedPlan, "dwd_fact")
+ assert(filteredSplits.size == 2)
+ }
+ }
+
def collectFilteredInputSplits(plan: SparkPlan, tableName: String):
Seq[Split] = {
flatMap(plan) {
case s: BatchScanExec =>