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 35beec197 [spark] Split non-partition and partition predicates from
pushPredicates to limit pushdown (#3397)
35beec197 is described below
commit 35beec19705a1ce9a3c254ec15ac536518473ae1
Author: Yang Zhang <[email protected]>
AuthorDate: Mon Jun 1 18:32:33 2026 +0800
[spark] Split non-partition and partition predicates from pushPredicates to
limit pushdown (#3397)
* fix
* fix style
* fix lake batch partition filter pushdown
* fix comments
* fix comments
---
.../org/apache/fluss/spark/read/FlussScan.scala | 4 +
.../apache/fluss/spark/read/FlussScanBuilder.scala | 37 +++++----
.../spark/read/lake/FlussLakeAppendBatch.scala | 57 ++++++++-----
.../spark/read/lake/FlussLakeUpsertBatch.scala | 67 +++++++++++-----
.../spark/utils/SparkPartitionPredicate.scala | 41 ++++++++--
.../apache/fluss/spark/SparkLogTableReadTest.scala | 1 +
.../fluss/spark/SparkPrimaryKeyTableReadTest.scala | 53 ++++++++++++
.../spark/lake/SparkLakeLogTableReadTest.scala | 93 ++++++++++++++++++++++
.../SparkLakePrimaryKeyTableReadTestBase.scala | 5 +-
.../spark/lake/SparkLakeTableReadTestBase.scala | 12 ++-
10 files changed, 302 insertions(+), 68 deletions(-)
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 c5379cfd8..f9d0be1bb 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
@@ -106,6 +106,7 @@ case class FlussLakeAppendScan(
tableInfo: TableInfo,
requiredSchema: Option[StructType],
pushedPredicate: Option[FlussPredicate],
+ override val partitionPredicate: Option[FlussPredicate],
override val pushedSparkPredicates: Seq[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
@@ -119,6 +120,7 @@ case class FlussLakeAppendScan(
tableInfo,
readSchema,
pushedPredicate,
+ partitionPredicate,
options,
flussConfig)
}
@@ -167,6 +169,7 @@ case class FlussLakeUpsertScan(
tableInfo: TableInfo,
requiredSchema: Option[StructType],
pushedPredicate: Option[FlussPredicate],
+ override val partitionPredicate: Option[FlussPredicate],
override val pushedSparkPredicates: Seq[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
@@ -180,6 +183,7 @@ case class FlussLakeUpsertScan(
tableInfo,
readSchema,
pushedPredicate,
+ partitionPredicate,
options,
flussConfig)
}
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 361542d76..6a9ece662 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
@@ -50,21 +50,21 @@ trait FlussSupportsPushDownPartitionFilters
def tableInfo: TableInfo
protected var partitionPredicate: Option[FlussPredicate] = None
+ protected var pushedPredicate: Option[FlussPredicate] = None
+ protected var acceptedPredicates: Array[Predicate] = Array.empty[Predicate]
override def pushPredicates(predicates: Array[Predicate]): Array[Predicate]
= {
- partitionPredicate = SparkPartitionPredicate.extract(tableInfo,
predicates.toSeq)
- predicates
+ val (nonPartitionPred, partitionPred) =
+ SparkPartitionPredicate.extract(tableInfo, predicates.toSeq)
+ partitionPredicate = partitionPred
+ nonPartitionPred.toArray
}
- override def pushedPredicates(): Array[Predicate] = Array.empty
+ override def pushedPredicates(): Array[Predicate] = acceptedPredicates
}
-/** 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]
-
protected def convertAndStorePredicates(predicates: Array[Predicate]): Unit
= {
val (predicate, accepted) =
SparkPredicateConverter.convertPredicates(tableInfo.getRowType,
predicates.toSeq)
@@ -73,15 +73,13 @@ trait FlussSupportsPushDownV2Filters extends
FlussSupportsPushDownPartitionFilte
}
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)
+ val nonPartitionPredicates = super.pushPredicates(predicates)
+ if (!tableInfo.hasPrimaryKey && tableInfo.getTableConfig.getLogFormat ==
LogFormat.ARROW) {
+ // Server-side batch filter for log table only supports ARROW; other log
formats reject it.
+ convertAndStorePredicates(nonPartitionPredicates)
}
- predicates
+ nonPartitionPredicates
}
-
- override def pushedPredicates(): Array[Predicate] = acceptedPredicates
}
/**
@@ -89,13 +87,16 @@ trait FlussSupportsPushDownV2Filters extends
FlussSupportsPushDownPartitionFilte
* offered to the lake source individually; only the lake-accepted subset is
reported back to Spark
* and combined into the predicate handed to the scan.
*/
-trait FlussLakeSupportsPushDownV2Filters extends
FlussSupportsPushDownV2Filters {
+trait FlussLakeSupportsPushDownV2Filters extends
FlussSupportsPushDownPartitionFilters {
def tablePath: TablePath
def flussConfig: FlussConfiguration
override def pushPredicates(predicates: Array[Predicate]): Array[Predicate]
= {
+ val nonPartitionPredicates = super.pushPredicates(predicates)
+
+ // Pass ALL predicates to Lake Source (including partition predicates) for
lake-side filtering
val pairs =
SparkPredicateConverter.convertPerPredicate(tableInfo.getRowType,
predicates.toSeq)
val (acceptedSpark, acceptedFluss) = if (pairs.isEmpty) {
@@ -112,7 +113,7 @@ trait FlussLakeSupportsPushDownV2Filters extends
FlussSupportsPushDownV2Filters
}
pushedPredicate = SparkPredicateConverter.combineAnd(acceptedFluss)
acceptedPredicates = acceptedSpark.toArray
- predicates
+ nonPartitionPredicates
}
}
@@ -151,6 +152,7 @@ class FlussLakeAppendScanBuilder(
tableInfo,
requiredSchema,
pushedPredicate,
+ partitionPredicate,
acceptedPredicates.toSeq,
options,
flussConfig)
@@ -163,7 +165,7 @@ class FlussUpsertScanBuilder(
val tableInfo: TableInfo,
options: CaseInsensitiveStringMap,
val flussConfig: FlussConfiguration)
- extends FlussSupportsPushDownPartitionFilters {
+ extends FlussSupportsPushDownV2Filters {
override def build(): Scan = {
FlussUpsertScan(tablePath, tableInfo, requiredSchema, partitionPredicate,
options, flussConfig)
@@ -184,6 +186,7 @@ class FlussLakeUpsertScanBuilder(
tableInfo,
requiredSchema,
pushedPredicate,
+ partitionPredicate,
acceptedPredicates.toSeq,
options,
flussConfig)
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeAppendBatch.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeAppendBatch.scala
index 0eb701555..f5b747e30 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeAppendBatch.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeAppendBatch.scala
@@ -25,6 +25,7 @@ import org.apache.fluss.lake.source.{LakeSource, LakeSplit}
import org.apache.fluss.metadata.{LogFormat, ResolvedPartitionSpec,
TableBucket, TableInfo, TablePath}
import org.apache.fluss.predicate.{Predicate => FlussPredicate}
import org.apache.fluss.spark.read._
+import org.apache.fluss.spark.utils.SparkPartitionPredicate
import org.apache.fluss.utils.ExceptionUtils
import org.apache.spark.sql.connector.read.{InputPartition,
PartitionReaderFactory}
@@ -40,6 +41,7 @@ class FlussLakeAppendBatch(
tableInfo: TableInfo,
readSchema: StructType,
pushedPredicate: Option[FlussPredicate],
+ partitionPredicate: Option[FlussPredicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussLakeBatch(tablePath, tableInfo, readSchema, options,
flussConfig) {
@@ -146,8 +148,14 @@ class FlussLakeAppendBatch(
bucketOffsetsRetriever: BucketOffsetsRetrieverImpl):
Array[InputPartition] = {
val tableId = tableInfo.getTableId
+ // Filter Fluss-known partitions using the partition predicate to skip
non-matching ones
+ val filteredPartitionInfos = SparkPartitionPredicate.filterPartitions(
+ tableInfo,
+ partitionInfos.asScala.toSeq,
+ partitionPredicate)
+
val flussPartitionIdByName = mutable.LinkedHashMap.empty[String, Long]
- partitionInfos.asScala.foreach {
+ filteredPartitionInfos.foreach {
pi => flussPartitionIdByName(pi.getPartitionName) = pi.getPartitionId
}
@@ -155,7 +163,7 @@ class FlussLakeAppendBatch(
var lakeSplitPartitionId = -1L
val lakeAndLogPartitions = lakeSplitsByPartition.flatMap {
- case (partitionName, splits) =>
+ case (partitionName, (partitionValues, splits)) =>
flussPartitionIdByName.remove(partitionName) match {
case Some(partitionId) =>
// Partition in both lake and Fluss — lake splits + log tail
@@ -176,10 +184,18 @@ class FlussLakeAppendBatch(
lakePartitions ++ logPartitions
case None =>
- // Partition only in lake (expired in Fluss) — lake splits only
- val pid = lakeSplitPartitionId
- lakeSplitPartitionId -= 1
- createLakePartitions(splits.toSeq, tableId, Some(pid))
+ // Partition only in lake (expired in Fluss). Apply the partition
predicate directly
+ // on the resolved partition values to avoid round-tripping
through partition names.
+ if (
+ SparkPartitionPredicate
+ .matchesPartition(tableInfo, partitionValues,
partitionPredicate)
+ ) {
+ val pid = lakeSplitPartitionId
+ lakeSplitPartitionId -= 1
+ createLakePartitions(splits.toSeq, tableId, Some(pid))
+ } else {
+ Seq.empty
+ }
}
}.toSeq
@@ -210,17 +226,20 @@ class FlussLakeAppendBatch(
(lakeAndLogPartitions ++ flussOnlyPartitions).toArray
}
- private def groupLakeSplitsByPartition(
- lakeSplits: Seq[LakeSplit]): mutable.LinkedHashMap[String,
mutable.ArrayBuffer[LakeSplit]] = {
- val grouped = mutable.LinkedHashMap.empty[String,
mutable.ArrayBuffer[LakeSplit]]
+ private def groupLakeSplitsByPartition(lakeSplits: Seq[LakeSplit])
+ : mutable.LinkedHashMap[String, (Seq[String],
mutable.ArrayBuffer[LakeSplit])] = {
+ val grouped =
+ mutable.LinkedHashMap.empty[String, (Seq[String],
mutable.ArrayBuffer[LakeSplit])]
lakeSplits.foreach {
split =>
- val partitionName = if (split.partition() == null ||
split.partition().isEmpty) {
- ""
- } else {
-
split.partition().asScala.mkString(ResolvedPartitionSpec.PARTITION_SPEC_SEPARATOR)
- }
- grouped.getOrElseUpdate(partitionName, mutable.ArrayBuffer.empty) +=
split
+ val partitionValues =
+ if (split.partition() == null) Seq.empty[String] else
split.partition().asScala.toSeq
+ val partitionName =
+ if (partitionValues.isEmpty) ""
+ else
partitionValues.mkString(ResolvedPartitionSpec.PARTITION_SPEC_SEPARATOR)
+ val (_, buf) =
+ grouped.getOrElseUpdate(partitionName, (partitionValues,
mutable.ArrayBuffer.empty))
+ buf += split
}
grouped
}
@@ -286,9 +305,11 @@ class FlussLakeAppendBatch(
}
if (tableInfo.isPartitioned) {
- partitionInfos.asScala.flatMap {
- pi => createPartitions(Some(pi.getPartitionId), pi.getPartitionName)
- }.toArray
+ val matching = SparkPartitionPredicate.filterPartitions(
+ tableInfo,
+ partitionInfos.asScala.toSeq,
+ partitionPredicate)
+ matching.flatMap(pi => createPartitions(Some(pi.getPartitionId),
pi.getPartitionName)).toArray
} else {
createPartitions(None, null)
}
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeUpsertBatch.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeUpsertBatch.scala
index 1b095751e..882d94143 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeUpsertBatch.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeUpsertBatch.scala
@@ -25,6 +25,7 @@ import org.apache.fluss.lake.source.LakeSplit
import org.apache.fluss.metadata.{ResolvedPartitionSpec, TableBucket,
TableInfo, TablePath}
import org.apache.fluss.predicate.{Predicate => FlussPredicate}
import org.apache.fluss.spark.read._
+import org.apache.fluss.spark.utils.SparkPartitionPredicate
import org.apache.fluss.utils.ExceptionUtils
import org.apache.spark.sql.connector.read.{InputPartition,
PartitionReaderFactory}
@@ -43,6 +44,7 @@ class FlussLakeUpsertBatch(
tableInfo: TableInfo,
readSchema: StructType,
pushedPredicate: Option[FlussPredicate],
+ partitionPredicate: Option[FlussPredicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussLakeBatch(tablePath, tableInfo, readSchema, options,
flussConfig) {
@@ -141,18 +143,24 @@ class FlussLakeUpsertBatch(
val tableId = tableInfo.getTableId
val buckets = (0 until tableInfo.getNumBuckets).toSeq
+ // Filter Fluss-known partitions using the partition predicate to skip
non-matching ones
+ val filteredPartitionInfos = SparkPartitionPredicate.filterPartitions(
+ tableInfo,
+ partitionInfos.asScala.toSeq,
+ partitionPredicate)
+
val flussPartitionIdByName = mutable.LinkedHashMap.empty[String, Long]
- partitionInfos.asScala.foreach {
+ filteredPartitionInfos.foreach {
pi => flussPartitionIdByName(pi.getPartitionName) = pi.getPartitionId
}
val lakeSplitsByPartition = groupLakeSplitsByPartition(lakeSplits)
val lakePartitions = lakeSplitsByPartition.flatMap {
- case (partitionName, splitsByBucket) =>
+ case (partitionName, (partitionValues, splitsByBucket)) =>
flussPartitionIdByName.remove(partitionName) match {
case Some(partitionId) =>
- // Partition in both lake and Fluss
+ // Partition in both lake and Fluss (already passed the predicate
filter above)
val stoppingOffsets = getBucketOffsets(
stoppingOffsetsInitializer,
partitionName,
@@ -173,13 +181,21 @@ class FlussLakeUpsertBatch(
}
case None =>
- // Partition only in lake (expired in Fluss)
- buckets.flatMap {
- bucketId =>
- val tableBucket = new TableBucket(tableId, -1, bucketId)
- splitsByBucket.getOrElse(bucketId, Seq.empty).map {
- lakeSplit => FlussLakeInputPartition(tableBucket, lakeSplit)
- }
+ // Partition only in lake (expired in Fluss). Apply the partition
predicate directly
+ // on the resolved partition values to avoid round-tripping
through partition names.
+ if (
+ SparkPartitionPredicate
+ .matchesPartition(tableInfo, partitionValues,
partitionPredicate)
+ ) {
+ buckets.flatMap {
+ bucketId =>
+ val tableBucket = new TableBucket(tableId, -1, bucketId)
+ splitsByBucket.getOrElse(bucketId, Seq.empty).map {
+ lakeSplit => FlussLakeInputPartition(tableBucket,
lakeSplit)
+ }
+ }
+ } else {
+ Seq.empty
}
}
}
@@ -216,18 +232,25 @@ class FlussLakeUpsertBatch(
(lakePartitions ++ flussOnlyPartitions).toArray
}
+ /**
+ * Group lake splits by partition. Each entry stores the resolved partition
values along with
+ * splits keyed by bucket id, so callers can both look up Fluss partitions
by name and evaluate
+ * the partition predicate directly on the values without re-parsing the
joined name.
+ */
private def groupLakeSplitsByPartition(
- lakeSplits: Seq[LakeSplit]): Map[String, mutable.Map[Int,
Seq[LakeSplit]]] = {
- val grouped = mutable.LinkedHashMap.empty[String, mutable.Map[Int,
Seq[LakeSplit]]]
+ lakeSplits: Seq[LakeSplit]): Map[String, (Seq[String], mutable.Map[Int,
Seq[LakeSplit]])] = {
+ val grouped =
+ mutable.LinkedHashMap.empty[String, (Seq[String], mutable.Map[Int,
Seq[LakeSplit]])]
lakeSplits.foreach {
split =>
- val partitionName = if (split.partition() == null ||
split.partition().isEmpty) {
- ""
- } else {
-
split.partition().asScala.mkString(ResolvedPartitionSpec.PARTITION_SPEC_SEPARATOR)
- }
+ val partitionValues =
+ if (split.partition() == null) Seq.empty[String] else
split.partition().asScala.toSeq
+ val partitionName =
+ if (partitionValues.isEmpty) ""
+ else
partitionValues.mkString(ResolvedPartitionSpec.PARTITION_SPEC_SEPARATOR)
+ val (_, bucketMap) =
+ grouped.getOrElseUpdate(partitionName, (partitionValues,
mutable.Map.empty))
val bucketId = split.bucket()
- val bucketMap = grouped.getOrElseUpdate(partitionName,
mutable.Map.empty)
val splits = bucketMap.getOrElse(bucketId, Seq.empty)
bucketMap(bucketId) = splits :+ split
}
@@ -271,7 +294,13 @@ class FlussLakeUpsertBatch(
val bucketOffsetsRetriever = new BucketOffsetsRetrieverImpl(admin,
tablePath)
if (tableInfo.isPartitioned) {
- partitionInfos.asScala.flatMap {
+ // Filter partitions using partition predicate early to skip
non-matching partitions
+ val filteredPartitionInfos = SparkPartitionPredicate.filterPartitions(
+ tableInfo,
+ partitionInfos.asScala.toSeq,
+ partitionPredicate)
+
+ filteredPartitionInfos.flatMap {
pi =>
val partitionName = pi.getPartitionName
val kvSnapshots = admin.getLatestKvSnapshots(tablePath,
partitionName).get()
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
index 353334c6e..6a92b309f 100644
---
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
@@ -28,25 +28,34 @@ 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] = {
+ def extract(
+ tableInfo: TableInfo,
+ predicates: Seq[Predicate]): (Seq[Predicate], Option[FlussPredicate]) = {
val partitionKeys = tableInfo.getPartitionKeys
- if (partitionKeys.isEmpty) return None
+ if (partitionKeys.isEmpty) {
+ return (predicates, None)
+ }
val rowType = PartitionUtils.partitionRowType(tableInfo)
val onlyPartitionKeys = new PartitionPredicateVisitor(partitionKeys)
- val converted = predicates.flatMap {
- sparkPredicate =>
- SparkPredicateConverter
- .convert(rowType, sparkPredicate)
- .filter(_.visit(onlyPartitionKeys))
+ val flussPredicates = predicates.map {
+ sparkPredicate => (sparkPredicate,
SparkPredicateConverter.convert(rowType, sparkPredicate))
+ }
+
+ val (partitionPairs, nonPartitionPairs) = flussPredicates.partition {
+ case (_, predicateOpt) =>
+ predicateOpt.exists(_.visit(onlyPartitionKeys))
}
- converted match {
+ val nonPartitionPredicates = nonPartitionPairs.map(_._1)
+ val partitionPredicate = partitionPairs.flatMap(_._2) match {
case Seq() => None
case Seq(single) => Some(single)
case many => Some(PredicateBuilder.and(many.asJava))
}
+
+ (nonPartitionPredicates, partitionPredicate)
}
def filterPartitions(
@@ -63,4 +72,20 @@ object SparkPartitionPredicate {
PartitionUtils.toPartitionRow(p.getResolvedPartitionSpec.getPartitionValues,
rowType))
}
}
+
+ /**
+ * Tests whether a partition (described by its ordered partition values)
matches the given
+ * predicate. Returns true when no predicate is provided.
+ */
+ def matchesPartition(
+ tableInfo: TableInfo,
+ partitionValues: Seq[String],
+ partitionPredicate: Option[FlussPredicate]): Boolean =
+ partitionPredicate match {
+ case None => true
+ case Some(_) if partitionValues.isEmpty => true
+ case Some(predicate) =>
+ val rowType = PartitionUtils.partitionRowType(tableInfo)
+ predicate.test(PartitionUtils.toPartitionRow(partitionValues.asJava,
rowType))
+ }
}
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 3276decb7..7c7fb8a5c 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
@@ -487,6 +487,7 @@ class SparkLogTableReadTest extends FlussSparkTestBase {
|WHERE dt = '2026-01-02' AND amount > 603 ORDER BY
orderId""".stripMargin)
checkAnswer(query, Row(900L) :: Nil)
assert(partitionPredicate(query).isDefined)
+ assert(pushedPredicates(query).nonEmpty)
}
}
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 cddca2172..84754f548 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
@@ -23,6 +23,8 @@ import org.apache.fluss.metadata.{TableBucket, TablePath}
import org.apache.fluss.spark.read.{FlussMetrics, FlussScan,
FlussUpsertInputPartition, FlussUpsertScan}
import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.execution.{FilterExec, SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec
import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceV2ScanRelation}
import org.assertj.core.api.Assertions.assertThat
@@ -432,6 +434,57 @@ class SparkPrimaryKeyTableReadTest extends
FlussSparkTestBase {
}
}
+ test("Spark Read: mixed partition and non-partition filter (PK table)") {
+ withPkPartitionedTable {
+ val query = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt =
'2026-01-01' AND amount > 601")
+ checkAnswer(query, Row(700L, 22L, 602, "addr2", "2026-01-01") :: Nil)
+ // Partition predicate extracted for partition pruning
+ assert(partitionPredicate(query).isDefined)
+ // Non-partition predicate (amount > 601) remains as a Filter node in
the plan
+ val executedPlan = query.queryExecution.executedPlan match {
+ case aqe: AdaptiveSparkPlanExec => aqe.executedPlan
+ case e: SparkPlan => e
+ }
+ assert(
+ executedPlan.exists(_.isInstanceOf[FilterExec]),
+ s"Expected Filter node in plan for non-partition predicate, got:
$executedPlan")
+
+ val numRowsRead = executedPlan
+ .collectFirst { case b: BatchScanExec =>
b.metrics(FlussMetrics.NUM_ROWS_READ).value }
+ .getOrElse(0L)
+ assert(numRowsRead == 2L, s"Expected 2 rows read for single partition,
got $numRowsRead")
+ }
+ }
+
+ test("Spark Read: partition-only filter should not leave FilterExec in plan
(PK table)") {
+ withPkPartitionedTable {
+ val query = sql(s"SELECT * FROM $DEFAULT_DATABASE.t WHERE dt =
'2026-01-01'")
+ checkAnswer(
+ query,
+ Row(700L, 22L, 602, "addr2", "2026-01-01") :: Row(
+ 600L,
+ 21L,
+ 601,
+ "addr1",
+ "2026-01-01") :: Nil)
+ // Partition predicate extracted for partition pruning
+ assert(partitionPredicate(query).isDefined)
+ // No FilterExec should remain since all predicates are partition
predicates
+ val executedPlan = query.queryExecution.executedPlan match {
+ case aqe: AdaptiveSparkPlanExec => aqe.executedPlan
+ case e: SparkPlan => e
+ }
+ assert(
+ !executedPlan.exists(_.isInstanceOf[FilterExec]),
+ s"Expected no Filter node in plan for partition-only predicate, got:
$executedPlan")
+
+ val numRowsRead = executedPlan
+ .collectFirst { case b: BatchScanExec =>
b.metrics(FlussMetrics.NUM_ROWS_READ).value }
+ .getOrElse(0L)
+ assert(numRowsRead == 2L, s"Expected 2 rows read for single partition,
got $numRowsRead")
+ }
+ }
+
private def withPkPartitionedTable(body: => Unit): Unit = withTable("t") {
sql(s"""
|CREATE TABLE $DEFAULT_DATABASE.t (
diff --git
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeLogTableReadTest.scala
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeLogTableReadTest.scala
index 210b23e12..b47bdfeeb 100644
---
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeLogTableReadTest.scala
+++
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeLogTableReadTest.scala
@@ -473,6 +473,99 @@ abstract class SparkLakeLogTableReadTest extends
SparkLakeTableReadTestBase {
}
}
+ test("Spark Lake Read: log table partition filter pushdown prunes
partitions") {
+ withTable("t_pd_part_lake") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_part_lake
+ | (id INT, name STRING, dt STRING)
+ | PARTITIONED BY (dt)
+ | TBLPROPERTIES (
+ | '${ConfigOptions.TABLE_DATALAKE_ENABLED.key()}' = true,
+ | '${ConfigOptions.TABLE_DATALAKE_FRESHNESS.key()}' = '1s',
+ | '${BUCKET_NUMBER.key()}' = 1)
+ |""".stripMargin)
+
+ // Lake-only partitions: tier all data, then filter by partition key.
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_part_lake VALUES
+ |(1, 'alpha', '2026-01-01'),
+ |(2, 'beta', '2026-01-01'),
+ |(3, 'gamma', '2026-01-02'),
+ |(4, 'delta', '2026-01-03')
+ |""".stripMargin)
+ tierToLake("t_pd_part_lake")
+
+ val lakeOnlyDf = sql(s"""
+ |SELECT * FROM $DEFAULT_DATABASE.t_pd_part_lake
+ |WHERE dt = '2026-01-02' ORDER BY
id""".stripMargin)
+ checkAnswer(lakeOnlyDf, Row(3, "gamma", "2026-01-02") :: Nil)
+ assert(
+ lakeInputPartitions(lakeOnlyDf).length == 1,
+ s"Expected 1 input partition after partition pruning"
+ )
+
+ // Append more data after tiering so the planner mixes lake splits and
Fluss log tail.
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_part_lake VALUES
+ |(5, 'epsilon', '2026-01-01'),
+ |(6, 'zeta', '2026-01-04')
+ |""".stripMargin)
+
+ val unionDf = sql(s"""
+ |SELECT * FROM $DEFAULT_DATABASE.t_pd_part_lake
+ |WHERE dt = '2026-01-01' ORDER BY id""".stripMargin)
+ checkAnswer(
+ unionDf,
+ Row(1, "alpha", "2026-01-01") ::
+ Row(2, "beta", "2026-01-01") ::
+ Row(5, "epsilon", "2026-01-01") :: Nil
+ )
+ // Only one partition should be planned: lake split + log tail for
dt='2026-01-01'.
+ val unionParts = lakeInputPartitions(unionDf)
+ assert(
+ unionParts.length == 2,
+ s"Expected 2 input partitions (one lake split + one log tail) after
pruning, " +
+ s"got ${unionParts.length}")
+
+ // Check the description carries the partition filter for visibility in
EXPLAIN output.
+ assert(
+
unionDf.queryExecution.executedPlan.toString.contains("PartitionFilter"),
+ s"Plan should contain
PartitionFilter:\n${unionDf.queryExecution.executedPlan}"
+ )
+ }
+ }
+
+ test("Spark Lake Read: log table partition filter pushdown in fallback (no
lake snapshot)") {
+ withTable("t_pd_part_fb") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_part_fb
+ | (id INT, name STRING, dt STRING)
+ | PARTITIONED BY (dt)
+ | TBLPROPERTIES (
+ | '${ConfigOptions.TABLE_DATALAKE_ENABLED.key()}' = true,
+ | '${ConfigOptions.TABLE_DATALAKE_FRESHNESS.key()}' = '1s',
+ | '${BUCKET_NUMBER.key()}' = 1)
+ |""".stripMargin)
+
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_part_fb VALUES
+ |(1, 'alpha', '2026-01-01'),
+ |(2, 'beta', '2026-01-02'),
+ |(3, 'gamma', '2026-01-03')
+ |""".stripMargin)
+
+ // No tiering performed -> falls back to reading directly from Fluss.
+ val df = sql(s"""
+ |SELECT * FROM $DEFAULT_DATABASE.t_pd_part_fb
+ |WHERE dt = '2026-01-02' ORDER BY id""".stripMargin)
+ checkAnswer(df, Row(2, "beta", "2026-01-02") :: Nil)
+ assert(
+ lakeInputPartitions(df).length == 1,
+ s"Expected fallback to plan 1 input partition after pruning"
+ )
+ }
+ }
+
test("Spark Lake Read: filter pushdown — partitioned lake table") {
withTable("t_pd_partitioned") {
sql(s"""
diff --git
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakePrimaryKeyTableReadTestBase.scala
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakePrimaryKeyTableReadTestBase.scala
index 0de25f100..f33157665 100644
---
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakePrimaryKeyTableReadTestBase.scala
+++
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakePrimaryKeyTableReadTestBase.scala
@@ -20,6 +20,7 @@ package org.apache.fluss.spark.lake
import org.apache.fluss.config.{ConfigOptions, Configuration}
import org.apache.fluss.metadata.DataLakeFormat
import org.apache.fluss.spark.SparkConnectorOptions.{BUCKET_NUMBER,
PRIMARY_KEY}
+import org.apache.fluss.spark.read.FlussUpsertInputPartition
import org.apache.spark.sql.Row
@@ -117,7 +118,7 @@ abstract class SparkLakePrimaryKeyTableReadTestBase extends
SparkLakeTableReadTe
|""".stripMargin)
val df = sql(s"SELECT * FROM $DEFAULT_DATABASE.t_fb_hybrid ORDER BY id")
- val partitions = lakeUpsertInputPartitions(df)
+ val partitions =
lakeInputPartitions(df).map(_.asInstanceOf[FlussUpsertInputPartition])
assert(
partitions.exists(_.snapshotId >= 0),
s"Expected at least one hybrid partition with snapshotId >= 0, got:
${partitions.mkString(", ")}")
@@ -157,7 +158,7 @@ abstract class SparkLakePrimaryKeyTableReadTestBase extends
SparkLakeTableReadTe
|""".stripMargin)
val df = sql(s"SELECT * FROM $DEFAULT_DATABASE.t_fb_hybrid_partitioned
ORDER BY id")
- val partitions = lakeUpsertInputPartitions(df)
+ val partitions =
lakeInputPartitions(df).map(_.asInstanceOf[FlussUpsertInputPartition])
assert(
partitions.exists(_.snapshotId >= 0),
s"Expected at least one hybrid partition with snapshotId >= 0, got:
${partitions.mkString(", ")}")
diff --git
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeTableReadTestBase.scala
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeTableReadTestBase.scala
index 526e89889..14108636e 100644
---
a/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeTableReadTestBase.scala
+++
b/fluss-spark/fluss-spark-ut/src/test/scala/org/apache/fluss/spark/lake/SparkLakeTableReadTestBase.scala
@@ -22,12 +22,13 @@ import org.apache.fluss.flink.tiering.LakeTieringJobBuilder
import org.apache.fluss.flink.tiering.source.TieringSourceOptions
import org.apache.fluss.metadata.{DataLakeFormat, TableBucket}
import org.apache.fluss.spark.FlussSparkTestBase
-import org.apache.fluss.spark.read.{FlussLakeUpsertScan, FlussScan,
FlussUpsertInputPartition}
+import org.apache.fluss.spark.read.{FlussLakeAppendScan, FlussLakeUpsertScan,
FlussScan}
import org.apache.flink.api.common.RuntimeExecutionMode
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.connector.expressions.filter.Predicate
+import org.apache.spark.sql.connector.read.InputPartition
import org.apache.spark.sql.execution.datasources.v2.{BatchScanExec,
DataSourceV2ScanRelation}
import java.time.Duration
@@ -152,7 +153,7 @@ abstract class SparkLakeTableReadTestBase extends
FlussSparkTestBase {
s"Expected any of $expected in pushed predicates, got $pushed")
}
- protected def lakeUpsertInputPartitions(df: DataFrame):
Array[FlussUpsertInputPartition] = {
+ protected def lakeInputPartitions(df: DataFrame): Array[InputPartition] = {
val scans =
df.queryExecution.executedPlan.collect {
case b: BatchScanExec => b.scan
@@ -160,8 +161,11 @@ abstract class SparkLakeTableReadTestBase extends
FlussSparkTestBase {
case DataSourceV2ScanRelation(_, scan, _, _, _) => scan
}
scans
- .collect { case s: FlussLakeUpsertScan => s }
- .flatMap(_.toBatch.planInputPartitions().collect { case p:
FlussUpsertInputPartition => p })
+ .collect {
+ case upsert: FlussLakeUpsertScan => upsert
+ case append: FlussLakeAppendScan => append
+ }
+ .flatMap(_.toBatch.planInputPartitions())
.toArray
}
}