This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git
The following commit(s) were added to refs/heads/main by this push:
new f894a0424 [spark] partition filter pushdown for non-lake log and kv
tables (#3240)
f894a0424 is described below
commit f894a04241b7d2234e01303d143327aa2d0d0d04
Author: Anton Borisov <[email protected]>
AuthorDate: Sat May 9 12:10:55 2026 +0100
[spark] partition filter pushdown for non-lake log and kv tables (#3240)
* [spark] partition filter pushdown for non-lake log and kv table
* address feedback
---
.../org/apache/fluss/spark/read/FlussBatch.scala | 11 ++-
.../org/apache/fluss/spark/read/FlussScan.scala | 24 ++++-
.../apache/fluss/spark/read/FlussScanBuilder.scala | 29 ++++--
.../spark/utils/SparkPartitionPredicate.scala | 106 +++++++++++++++++++++
.../apache/fluss/spark/SparkLogTableReadTest.scala | 87 ++++++++++++++++-
.../fluss/spark/SparkPrimaryKeyTableReadTest.scala | 90 ++++++++++++++++-
6 files changed, 329 insertions(+), 18 deletions(-)
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussBatch.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussBatch.scala
index 0fceef772..128094a04 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussBatch.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussBatch.scala
@@ -25,6 +25,7 @@ import org.apache.fluss.client.table.scanner.log.LogScanner
import org.apache.fluss.config.Configuration
import org.apache.fluss.metadata.{PartitionInfo, TableBucket, TableInfo,
TablePath}
import org.apache.fluss.predicate.Predicate
+import org.apache.fluss.spark.utils.SparkPartitionPredicate
import org.apache.spark.sql.connector.read.{Batch, InputPartition,
PartitionReaderFactory}
import org.apache.spark.sql.types.StructType
@@ -78,6 +79,7 @@ class FlussAppendBatch(
tableInfo: TableInfo,
readSchema: StructType,
pushedPredicate: Option[Predicate],
+ partitionPredicate: Option[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussBatch(tablePath, tableInfo, readSchema, flussConfig) {
@@ -116,7 +118,9 @@ class FlussAppendBatch(
}
if (tableInfo.isPartitioned) {
- partitionInfos.asScala
+ val matching =
+ SparkPartitionPredicate.filterPartitions(partitionInfos.asScala.toSeq,
partitionPredicate)
+ matching
.map {
partitionInfo =>
val startBucketOffsets = startOffsetsInitializer.getBucketOffsets(
@@ -172,6 +176,7 @@ class FlussUpsertBatch(
tablePath: TablePath,
tableInfo: TableInfo,
readSchema: StructType,
+ partitionPredicate: Option[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussBatch(tablePath, tableInfo, readSchema, flussConfig) {
@@ -234,7 +239,9 @@ class FlussUpsertBatch(
}
if (tableInfo.isPartitioned) {
- partitionInfos.asScala.flatMap {
+ val matching =
+ SparkPartitionPredicate.filterPartitions(partitionInfos.asScala.toSeq,
partitionPredicate)
+ matching.flatMap {
partitionInfo =>
val partitionName = partitionInfo.getPartitionName
val kvSnapshots =
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScan.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScan.scala
index 916a125b7..c5379cfd8 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScan.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScan.scala
@@ -41,6 +41,8 @@ trait FlussScan extends Scan {
/** Spark predicates that the scan reports as pushed down (used in
[[description]]). */
def pushedSparkPredicates: Seq[Predicate] = Seq.empty
+ def partitionPredicate: Option[FlussPredicate] = None
+
protected def scanType: String
override def readSchema(): StructType = {
@@ -49,8 +51,13 @@ trait FlussScan extends Scan {
override def description(): String = {
val base = s"FlussScan: [$tablePath], Type: [$scanType]"
- if (pushedSparkPredicates.isEmpty) base
- else s"$base [PushedPredicates: ${pushedSparkPredicates.mkString("[", ",
", "]")}]"
+ val withPushed =
+ if (pushedSparkPredicates.isEmpty) base
+ else s"$base [PushedPredicates: ${pushedSparkPredicates.mkString("[", ",
", "]")}]"
+ partitionPredicate match {
+ case Some(p) => s"$withPushed [PartitionFilter: $p]"
+ case None => withPushed
+ }
}
override def supportedCustomMetrics(): Array[CustomMetric] =
@@ -63,6 +70,7 @@ case class FlussAppendScan(
tableInfo: TableInfo,
requiredSchema: Option[StructType],
pushedPredicate: Option[FlussPredicate],
+ override val partitionPredicate: Option[FlussPredicate],
override val pushedSparkPredicates: Seq[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
@@ -71,7 +79,14 @@ case class FlussAppendScan(
override protected val scanType: String = "Append"
override def toBatch: Batch = {
- new FlussAppendBatch(tablePath, tableInfo, readSchema, pushedPredicate,
options, flussConfig)
+ new FlussAppendBatch(
+ tablePath,
+ tableInfo,
+ readSchema,
+ pushedPredicate,
+ partitionPredicate,
+ options,
+ flussConfig)
}
override def toMicroBatchStream(checkpointLocation: String):
MicroBatchStream = {
@@ -124,6 +139,7 @@ case class FlussUpsertScan(
tablePath: TablePath,
tableInfo: TableInfo,
requiredSchema: Option[StructType],
+ override val partitionPredicate: Option[FlussPredicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussScan {
@@ -131,7 +147,7 @@ case class FlussUpsertScan(
override protected val scanType: String = "Upsert"
override def toBatch: Batch = {
- new FlussUpsertBatch(tablePath, tableInfo, readSchema, options,
flussConfig)
+ new FlussUpsertBatch(tablePath, tableInfo, readSchema, partitionPredicate,
options, flussConfig)
}
override def toMicroBatchStream(checkpointLocation: String):
MicroBatchStream = {
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScanBuilder.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScanBuilder.scala
index c74f43b9c..9e1c0d454 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScanBuilder.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussScanBuilder.scala
@@ -21,7 +21,7 @@ import org.apache.fluss.config.{Configuration =>
FlussConfiguration}
import org.apache.fluss.metadata.{LogFormat, TableInfo, TablePath}
import org.apache.fluss.predicate.{Predicate => FlussPredicate}
import org.apache.fluss.spark.read.lake.{FlussLakeBatch, FlussLakeUtils}
-import org.apache.fluss.spark.utils.SparkPredicateConverter
+import org.apache.fluss.spark.utils.{SparkPartitionPredicate,
SparkPredicateConverter}
import org.apache.spark.sql.connector.expressions.filter.Predicate
import org.apache.spark.sql.connector.read.{Scan, ScanBuilder,
SupportsPushDownRequiredColumns, SupportsPushDownV2Filters}
@@ -42,11 +42,26 @@ trait FlussScanBuilder extends ScanBuilder with
SupportsPushDownRequiredColumns
}
}
-/** Predicate pushdown mixin: converts what it can, returns all predicates as
residual. */
-trait FlussSupportsPushDownV2Filters extends FlussScanBuilder with
SupportsPushDownV2Filters {
+/** Extracts a partition-key predicate so the scan can skip partitions that
can't match. */
+trait FlussSupportsPushDownPartitionFilters
+ extends FlussScanBuilder
+ with SupportsPushDownV2Filters {
def tableInfo: TableInfo
+ protected var partitionPredicate: Option[FlussPredicate] = None
+
+ override def pushPredicates(predicates: Array[Predicate]): Array[Predicate]
= {
+ partitionPredicate = SparkPartitionPredicate.extract(tableInfo,
predicates.toSeq)
+ predicates
+ }
+
+ override def pushedPredicates(): Array[Predicate] = Array.empty
+}
+
+/** Adds ARROW server-side log filtering on top of partition pushdown. */
+trait FlussSupportsPushDownV2Filters extends
FlussSupportsPushDownPartitionFilters {
+
protected var pushedPredicate: Option[FlussPredicate] = None
protected var acceptedPredicates: Array[Predicate] = Array.empty[Predicate]
@@ -58,6 +73,7 @@ trait FlussSupportsPushDownV2Filters extends FlussScanBuilder
with SupportsPushD
}
override def pushPredicates(predicates: Array[Predicate]): Array[Predicate]
= {
+ super.pushPredicates(predicates)
// Server-side batch filter only supports ARROW; other log formats reject
it.
if (tableInfo.getTableConfig.getLogFormat == LogFormat.ARROW) {
convertAndStorePredicates(predicates)
@@ -112,6 +128,7 @@ class FlussAppendScanBuilder(
tableInfo,
requiredSchema,
pushedPredicate,
+ partitionPredicate,
acceptedPredicates.toSeq,
options,
flussConfig)
@@ -141,13 +158,13 @@ class FlussLakeAppendScanBuilder(
/** Fluss Upsert Scan Builder. */
class FlussUpsertScanBuilder(
tablePath: TablePath,
- tableInfo: TableInfo,
+ val tableInfo: TableInfo,
options: CaseInsensitiveStringMap,
flussConfig: FlussConfiguration)
- extends FlussScanBuilder {
+ extends FlussSupportsPushDownPartitionFilters {
override def build(): Scan = {
- FlussUpsertScan(tablePath, tableInfo, requiredSchema, options, flussConfig)
+ FlussUpsertScan(tablePath, tableInfo, requiredSchema, partitionPredicate,
options, flussConfig)
}
}
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPartitionPredicate.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPartitionPredicate.scala
new file mode 100644
index 000000000..cf300892a
--- /dev/null
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPartitionPredicate.scala
@@ -0,0 +1,106 @@
+/*
+ * 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.fluss.spark.utils
+
+import org.apache.fluss.metadata.{PartitionInfo, TableInfo}
+import org.apache.fluss.predicate.{CompoundPredicate, LeafPredicate,
PartitionPredicateVisitor, Predicate => FlussPredicate, PredicateBuilder,
PredicateVisitor}
+import org.apache.fluss.row.{BinaryString, GenericRow}
+import org.apache.fluss.types.{DataTypes, RowType}
+import org.apache.fluss.utils.PartitionUtils
+
+import org.apache.spark.sql.connector.expressions.filter.Predicate
+
+import scala.jdk.CollectionConverters._
+
+/** Extracts a partition-key predicate and prunes the partition list at
planning time. */
+object SparkPartitionPredicate {
+
+ def extract(tableInfo: TableInfo, predicates: Seq[Predicate]):
Option[FlussPredicate] = {
+ val partitionKeys = tableInfo.getPartitionKeys
+ if (partitionKeys.isEmpty) return None
+
+ val rowType = partitionRowType(tableInfo)
+ val onlyPartitionKeys = new PartitionPredicateVisitor(partitionKeys)
+
+ val converted = predicates.flatMap {
+ sparkPredicate =>
+ SparkPredicateConverter
+ .convert(rowType, sparkPredicate)
+ .filter(_.visit(onlyPartitionKeys))
+ .map(stringifyLiterals)
+ }
+
+ converted match {
+ case Seq() => None
+ case Seq(single) => Some(single)
+ case many => Some(PredicateBuilder.and(many.asJava))
+ }
+ }
+
+ def filterPartitions(
+ partitionInfos: Seq[PartitionInfo],
+ partitionPredicate: Option[FlussPredicate]): Seq[PartitionInfo] =
+ partitionPredicate match {
+ case None => partitionInfos
+ case Some(predicate) => partitionInfos.filter(p =>
predicate.test(toPartitionRow(p)))
+ }
+
+ private def partitionRowType(tableInfo: TableInfo): RowType = {
+ val schemaRowType = tableInfo.getRowType
+ val fieldNames = schemaRowType.getFieldNames
+ val partitionFieldIndexes =
tableInfo.getPartitionKeys.asScala.map(fieldNames.indexOf).toArray
+ schemaRowType.project(partitionFieldIndexes)
+ }
+
+ private def toPartitionRow(partitionInfo: PartitionInfo): GenericRow = {
+ val values = partitionInfo.getResolvedPartitionSpec.getPartitionValues
+ val row = new GenericRow(values.size)
+ var i = 0
+ while (i < values.size) {
+ row.setField(i, BinaryString.fromString(values.get(i)))
+ i += 1
+ }
+ row
+ }
+
+ // Partition values are stored as strings; literals must be coerced before
evaluation.
+ private val stringifier: PredicateVisitor[FlussPredicate] = new
PredicateVisitor[FlussPredicate] {
+ override def visit(leaf: LeafPredicate): FlussPredicate = {
+ val converted: Seq[Object] = leaf.literals.asScala.toSeq.map {
+ case null => null
+ case literal =>
+ BinaryString.fromString(
+ PartitionUtils.convertValueOfType(literal,
leaf.`type`.getTypeRoot))
+ }
+ new LeafPredicate(
+ leaf.function,
+ DataTypes.STRING,
+ leaf.index,
+ leaf.fieldName,
+ converted.asJava)
+ }
+
+ override def visit(compound: CompoundPredicate): FlussPredicate = {
+ val children = compound.children.asScala.map(_.visit(this)).asJava
+ new CompoundPredicate(compound.function, children)
+ }
+ }
+
+ private def stringifyLiterals(predicate: FlussPredicate): FlussPredicate =
+ predicate.visit(stringifier)
+}
diff --git
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkLogTableReadTest.scala
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkLogTableReadTest.scala
index d616478aa..42b0aa62d 100644
---
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkLogTableReadTest.scala
+++
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkLogTableReadTest.scala
@@ -448,9 +448,83 @@ class SparkLogTableReadTest extends FlussSparkTestBase {
sql(s"SELECT orderId, amount, dt FROM $DEFAULT_DATABASE.t WHERE amount
= 603")
checkAnswer(query, Row(800L, 603, "2026-01-02") :: Nil)
assertPushedNames(query, Set("="))
+ // amount is not a partition key — no partition predicate extracted.
+ assert(partitionPredicate(query).isEmpty)
}
}
+ test("Spark Read: partition pushdown — equality on partition key") {
+ withPartitionedTable {
+ val query =
+ sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt = '2026-01-01' ORDER
BY orderId")
+ checkAnswer(
+ query,
+ Row(600L, 21L, 601, "addr1", "2026-01-01") ::
+ Row(700L, 22L, 602, "addr2", "2026-01-01") :: Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: partition pushdown — IN on partition key") {
+ withPartitionedTable {
+ val query = sql(s"""
+ |SELECT orderId, dt FROM $DEFAULT_DATABASE.t
+ |WHERE dt IN ('2026-01-01', '2026-01-03') ORDER BY
orderId""".stripMargin)
+ checkAnswer(
+ query,
+ Row(600L, "2026-01-01") ::
+ Row(700L, "2026-01-01") ::
+ Row(1000L, "2026-01-03") :: Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: partition pushdown — mixed partition and data column") {
+ withPartitionedTable {
+ // dt portion goes to partition pruning; amount portion is residual /
data-side pushdown.
+ val query = sql(s"""
+ |SELECT orderId FROM $DEFAULT_DATABASE.t
+ |WHERE dt = '2026-01-02' AND amount > 603 ORDER BY
orderId""".stripMargin)
+ checkAnswer(query, Row(900L) :: Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: partition pushdown — non-matching partition prunes to
empty") {
+ withPartitionedTable {
+ val query = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt =
'2099-01-01'")
+ checkAnswer(query, Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: scan description surfaces partition filter when pushed") {
+ withPartitionedTable {
+ val withPart = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt =
'2026-01-01'")
+ val noPart = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE amount = 603")
+ val withDesc = flussAppendScans(withPart).head.description()
+ val noDesc = flussAppendScans(noPart).head.description()
+ assert(withDesc.contains("[PartitionFilter:"))
+ assert(withDesc.contains("dt"))
+ assert(!noDesc.contains("PartitionFilter"))
+ }
+ }
+
+ private def withPartitionedTable(body: => Unit): Unit = withTable("t") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t (
+ | orderId BIGINT, itemId BIGINT, amount INT, address STRING, dt
STRING
+ |)
+ |PARTITIONED BY (dt)""".stripMargin)
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t VALUES
+ |(600L, 21L, 601, "addr1", "2026-01-01"), (700L, 22L, 602, "addr2",
"2026-01-01"),
+ |(800L, 23L, 603, "addr3", "2026-01-02"), (900L, 24L, 604, "addr4",
"2026-01-02"),
+ |(1000L, 25L, 605, "addr5", "2026-01-03")
+ |""".stripMargin)
+ body
+ }
+
private def withSampleTable(body: => Unit): Unit = withTable("t") {
sql(s"""
|CREATE TABLE $DEFAULT_DATABASE.t (
@@ -469,6 +543,14 @@ class SparkLogTableReadTest extends FlussSparkTestBase {
}
private def pushedPredicates(df: DataFrame): Array[Predicate] = {
+ flussAppendScans(df).flatMap(_.pushedSparkPredicates).toArray
+ }
+
+ private def partitionPredicate(df: DataFrame):
Option[org.apache.fluss.predicate.Predicate] = {
+ flussAppendScans(df).flatMap(_.partitionPredicate).headOption
+ }
+
+ private def flussAppendScans(df: DataFrame): Seq[FlussAppendScan] = {
// AQE hides the scan under an adaptive wrapper in executedPlan, so check
optimizedPlan too.
val scans =
df.queryExecution.executedPlan.collect {
@@ -476,10 +558,7 @@ class SparkLogTableReadTest extends FlussSparkTestBase {
} ++ df.queryExecution.optimizedPlan.collect {
case DataSourceV2ScanRelation(_, scan, _, _, _) => scan
}
- scans
- .collect { case f: FlussAppendScan => f.pushedSparkPredicates }
- .flatten
- .toArray
+ scans.collect { case f: FlussAppendScan => f }
}
private def assertPushedNames(df: DataFrame, expected: Set[String]): Unit = {
diff --git
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
index 6647ba2be..cddca2172 100644
---
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
+++
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/SparkPrimaryKeyTableReadTest.scala
@@ -20,9 +20,9 @@ package org.apache.fluss.spark
import org.apache.fluss.client.initializer.{BucketOffsetsRetrieverImpl,
OffsetsInitializer}
import org.apache.fluss.config.{ConfigOptions, Configuration}
import org.apache.fluss.metadata.{TableBucket, TablePath}
-import org.apache.fluss.spark.read.{FlussMetrics, FlussScan,
FlussUpsertInputPartition}
+import org.apache.fluss.spark.read.{FlussMetrics, FlussScan,
FlussUpsertInputPartition, FlussUpsertScan}
-import org.apache.spark.sql.Row
+import org.apache.spark.sql.{DataFrame, Row}
import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceV2ScanRelation}
import org.assertj.core.api.Assertions.assertThat
@@ -376,4 +376,90 @@ class SparkPrimaryKeyTableReadTest extends
FlussSparkTestBase {
assert(numRowsRead == 3L, s"Expected 3 rows read, got $numRowsRead")
}
}
+
+ test("Spark Read: partition pushdown — equality on partition key (PK
table)") {
+ withPkPartitionedTable {
+ val query =
+ sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt = '2026-01-01' ORDER
BY orderId")
+ checkAnswer(
+ query,
+ Row(600L, 21L, 601, "addr1", "2026-01-01") ::
+ Row(700L, 22L, 602, "addr2", "2026-01-01") :: Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: partition pushdown — IN on partition key (PK table)") {
+ withPkPartitionedTable {
+ val query = sql(s"""
+ |SELECT orderId, dt FROM $DEFAULT_DATABASE.t
+ |WHERE dt IN ('2026-01-01', '2026-01-03') ORDER BY
orderId""".stripMargin)
+ checkAnswer(
+ query,
+ Row(600L, "2026-01-01") ::
+ Row(700L, "2026-01-01") ::
+ Row(1000L, "2026-01-03") :: Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: partition pushdown — non-matching partition prunes to
empty (PK table)") {
+ withPkPartitionedTable {
+ val query = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt =
'2099-01-01'")
+ checkAnswer(query, Nil)
+ assert(partitionPredicate(query).isDefined)
+ }
+ }
+
+ test("Spark Read: partition pushdown — predicate on non-partition column not
extracted") {
+ withPkPartitionedTable {
+ val query = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE amount = 603
ORDER BY orderId")
+ checkAnswer(query, Row(800L, 23L, 603, "addr3", "2026-01-02") :: Nil)
+ // amount is not a partition key — no partition predicate extracted.
+ assert(partitionPredicate(query).isEmpty)
+ }
+ }
+
+ test("Spark Read: scan description surfaces partition filter when pushed (PK
table)") {
+ withPkPartitionedTable {
+ val withPart = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt =
'2026-01-01'")
+ val noPart = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE amount = 603")
+ val withDesc = flussUpsertScan(withPart).get.description()
+ val noDesc = flussUpsertScan(noPart).get.description()
+ assert(withDesc.contains("[PartitionFilter:"))
+ assert(withDesc.contains("dt"))
+ assert(!noDesc.contains("PartitionFilter"))
+ }
+ }
+
+ private def withPkPartitionedTable(body: => Unit): Unit = withTable("t") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t (
+ | orderId BIGINT, itemId BIGINT, amount INT, address STRING, dt
STRING
+ |)
+ |PARTITIONED BY (dt)
+ |TBLPROPERTIES("primary.key" = "orderId,dt", "bucket.num" =
1)""".stripMargin)
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t VALUES
+ |(600L, 21L, 601, "addr1", "2026-01-01"), (700L, 22L, 602, "addr2",
"2026-01-01"),
+ |(800L, 23L, 603, "addr3", "2026-01-02"), (900L, 24L, 604, "addr4",
"2026-01-02"),
+ |(1000L, 25L, 605, "addr5", "2026-01-03")
+ |""".stripMargin)
+ body
+ }
+
+ private def partitionPredicate(df: DataFrame):
Option[org.apache.fluss.predicate.Predicate] = {
+ flussUpsertScan(df).flatMap(_.partitionPredicate)
+ }
+
+ private def flussUpsertScan(df: DataFrame): Option[FlussUpsertScan] = {
+ // AQE hides the scan under an adaptive wrapper in executedPlan, so check
optimizedPlan too.
+ val scans =
+ df.queryExecution.executedPlan.collect {
+ case b: BatchScanExec => b.scan
+ } ++ df.queryExecution.optimizedPlan.collect {
+ case DataSourceV2ScanRelation(_, scan, _, _, _) => scan
+ }
+ scans.collectFirst { case f: FlussUpsertScan => f }
+ }
}