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 cfa80980d [spark] lake table predicate pushdown (#3238)
cfa80980d is described below
commit cfa80980d60f432623cb67db3f677f39651ce317
Author: Anton Borisov <[email protected]>
AuthorDate: Fri May 8 15:56:10 2026 +0100
[spark] lake table predicate pushdown (#3238)
* [spark] lake tables predicate pushdown
* [spark] address review on lake predicate pushdown
* [spark] address copilot review on lake predicate pushdown
* [spark] address luoyuxia review on lake predicate pushdown
* debug to info
---
.../org/apache/fluss/spark/read/FlussScan.scala | 38 +++++--
.../apache/fluss/spark/read/FlussScanBuilder.scala | 78 ++++++++++---
.../spark/read/lake/FlussLakeAppendBatch.scala | 18 ++-
.../fluss/spark/read/lake/FlussLakeBatch.scala | 23 ++++
.../lake/FlussLakePartitionReaderFactory.scala | 11 +-
.../spark/read/lake/FlussLakeUpsertBatch.scala | 6 +
.../spark/utils/SparkPredicateConverter.scala | 23 ++--
.../spark/lake/SparkLakeLogTableReadTest.scala | 126 +++++++++++++++++++++
.../SparkLakePrimaryKeyTableReadTestBase.scala | 51 +++++++++
.../spark/lake/SparkLakeTableReadTestBase.scala | 23 ++++
10 files changed, 362 insertions(+), 35 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 d979a5fd8..916a125b7 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
@@ -38,14 +38,20 @@ trait FlussScan extends Scan {
def requiredSchema: Option[StructType]
+ /** Spark predicates that the scan reports as pushed down (used in
[[description]]). */
+ def pushedSparkPredicates: Seq[Predicate] = Seq.empty
+
protected def scanType: String
override def readSchema(): StructType = {
requiredSchema.getOrElse(SparkConversions.toSparkDataType(tableInfo.getRowType))
}
- override def description(): String =
- s"FlussScan: [$tablePath], Type: [$scanType]"
+ override def description(): String = {
+ val base = s"FlussScan: [$tablePath], Type: [$scanType]"
+ if (pushedSparkPredicates.isEmpty) base
+ else s"$base [PushedPredicates: ${pushedSparkPredicates.mkString("[", ",
", "]")}]"
+ }
override def supportedCustomMetrics(): Array[CustomMetric] =
Array(FlussNumRowsReadMetric())
@@ -57,7 +63,7 @@ case class FlussAppendScan(
tableInfo: TableInfo,
requiredSchema: Option[StructType],
pushedPredicate: Option[FlussPredicate],
- pushedSparkPredicates: Seq[Predicate],
+ override val pushedSparkPredicates: Seq[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussScan {
@@ -77,12 +83,6 @@ case class FlussAppendScan(
flussConfig,
checkpointLocation)
}
-
- override def description(): String = {
- val base = super.description()
- if (pushedSparkPredicates.isEmpty) base
- else s"$base [PushedPredicates: ${pushedSparkPredicates.mkString("[", ",
", "]")}]"
- }
}
/** Fluss Lake Append Scan. */
@@ -90,6 +90,8 @@ case class FlussLakeAppendScan(
tablePath: TablePath,
tableInfo: TableInfo,
requiredSchema: Option[StructType],
+ pushedPredicate: Option[FlussPredicate],
+ override val pushedSparkPredicates: Seq[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussScan {
@@ -97,7 +99,13 @@ case class FlussLakeAppendScan(
override protected val scanType: String = "LakeAppend"
override def toBatch: Batch = {
- new FlussLakeAppendBatch(tablePath, tableInfo, readSchema, options,
flussConfig)
+ new FlussLakeAppendBatch(
+ tablePath,
+ tableInfo,
+ readSchema,
+ pushedPredicate,
+ options,
+ flussConfig)
}
override def toMicroBatchStream(checkpointLocation: String):
MicroBatchStream = {
@@ -142,6 +150,8 @@ case class FlussLakeUpsertScan(
tablePath: TablePath,
tableInfo: TableInfo,
requiredSchema: Option[StructType],
+ pushedPredicate: Option[FlussPredicate],
+ override val pushedSparkPredicates: Seq[Predicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussScan {
@@ -149,7 +159,13 @@ case class FlussLakeUpsertScan(
override protected val scanType: String = "LakeUpsert"
override def toBatch: Batch = {
- new FlussLakeUpsertBatch(tablePath, tableInfo, readSchema, options,
flussConfig)
+ new FlussLakeUpsertBatch(
+ tablePath,
+ tableInfo,
+ readSchema,
+ pushedPredicate,
+ 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 ef08d9de8..c74f43b9c 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
@@ -20,6 +20,7 @@ package org.apache.fluss.spark.read
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.spark.sql.connector.expressions.filter.Predicate
@@ -27,6 +28,10 @@ import org.apache.spark.sql.connector.read.{Scan,
ScanBuilder, SupportsPushDownR
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import java.util.{Collections, IdentityHashMap, Set => JSet}
+
+import scala.collection.JavaConverters._
+
/** An interface that extends from Spark [[ScanBuilder]]. */
trait FlussScanBuilder extends ScanBuilder with
SupportsPushDownRequiredColumns {
@@ -45,21 +50,54 @@ trait FlussSupportsPushDownV2Filters extends
FlussScanBuilder with SupportsPushD
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)
+ pushedPredicate = predicate
+ acceptedPredicates = accepted.toArray
+ }
+
override def pushPredicates(predicates: Array[Predicate]): Array[Predicate]
= {
// Server-side batch filter only supports ARROW; other log formats reject
it.
if (tableInfo.getTableConfig.getLogFormat == LogFormat.ARROW) {
- val (predicate, accepted) =
- SparkPredicateConverter.convertPredicates(tableInfo.getRowType,
predicates.toSeq)
- pushedPredicate = predicate
- acceptedPredicates = accepted.toArray
+ convertAndStorePredicates(predicates)
}
- // Server-side filter is batch-level only; Spark must re-apply for
row-exact results.
predicates
}
override def pushedPredicates(): Array[Predicate] = acceptedPredicates
}
+/**
+ * Lake reads push to the lake source regardless of log format. Each
convertible predicate is
+ * 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 {
+
+ def tablePath: TablePath
+
+ override def pushPredicates(predicates: Array[Predicate]): Array[Predicate]
= {
+ val pairs =
+ SparkPredicateConverter.convertPerPredicate(tableInfo.getRowType,
predicates.toSeq)
+ val (acceptedSpark, acceptedFluss) = if (pairs.isEmpty) {
+ (Seq.empty[Predicate], Seq.empty[FlussPredicate])
+ } else {
+ val lakeSource =
+ FlussLakeUtils.createLakeSource(tableInfo.getProperties.toMap,
tablePath)
+ val result = FlussLakeBatch.applyLakeFilters(lakeSource,
pairs.map(_._2).asJava)
+ // Identity-match: lake sources are expected to return the same
instances they received.
+ val acceptedSet: JSet[FlussPredicate] =
+ Collections.newSetFromMap(new IdentityHashMap())
+ acceptedSet.addAll(result.acceptedPredicates())
+ pairs.collect { case (sp, fp) if acceptedSet.contains(fp) => (sp, fp)
}.unzip
+ }
+ pushedPredicate = SparkPredicateConverter.combineAnd(acceptedFluss)
+ acceptedPredicates = acceptedSpark.toArray
+ predicates
+ }
+}
+
/** Fluss Append Scan Builder. */
class FlussAppendScanBuilder(
tablePath: TablePath,
@@ -82,14 +120,21 @@ class FlussAppendScanBuilder(
/** Fluss Lake Append Scan Builder. */
class FlussLakeAppendScanBuilder(
- tablePath: TablePath,
- tableInfo: TableInfo,
+ val tablePath: TablePath,
+ val tableInfo: TableInfo,
options: CaseInsensitiveStringMap,
flussConfig: FlussConfiguration)
- extends FlussScanBuilder {
+ extends FlussLakeSupportsPushDownV2Filters {
override def build(): Scan = {
- FlussLakeAppendScan(tablePath, tableInfo, requiredSchema, options,
flussConfig)
+ FlussLakeAppendScan(
+ tablePath,
+ tableInfo,
+ requiredSchema,
+ pushedPredicate,
+ acceptedPredicates.toSeq,
+ options,
+ flussConfig)
}
}
@@ -108,13 +153,20 @@ class FlussUpsertScanBuilder(
/** Fluss Lake Upsert Scan Builder for lake-enabled primary key tables. */
class FlussLakeUpsertScanBuilder(
- tablePath: TablePath,
- tableInfo: TableInfo,
+ val tablePath: TablePath,
+ val tableInfo: TableInfo,
options: CaseInsensitiveStringMap,
flussConfig: FlussConfiguration)
- extends FlussScanBuilder {
+ extends FlussLakeSupportsPushDownV2Filters {
override def build(): Scan = {
- FlussLakeUpsertScan(tablePath, tableInfo, requiredSchema, options,
flussConfig)
+ FlussLakeUpsertScan(
+ tablePath,
+ tableInfo,
+ requiredSchema,
+ pushedPredicate,
+ 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 a8c2ec195..d45103929 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
@@ -22,7 +22,8 @@ import org.apache.fluss.client.table.scanner.log.LogScanner
import org.apache.fluss.config.Configuration
import org.apache.fluss.exception.LakeTableSnapshotNotExistException
import org.apache.fluss.lake.source.{LakeSource, LakeSplit}
-import org.apache.fluss.metadata.{ResolvedPartitionSpec, TableBucket,
TableInfo, TablePath}
+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.utils.ExceptionUtils
@@ -38,6 +39,7 @@ class FlussLakeAppendBatch(
tablePath: TablePath,
tableInfo: TableInfo,
readSchema: StructType,
+ pushedPredicate: Option[FlussPredicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussLakeBatch(tablePath, tableInfo, readSchema, options,
flussConfig) {
@@ -45,14 +47,25 @@ class FlussLakeAppendBatch(
// Required by FlussLakeBatch but unused — lake snapshot determines start
offsets.
override val startOffsetsInitializer: OffsetsInitializer =
OffsetsInitializer.earliest()
+ // Server-side log filter requires ARROW format.
+ private val logTailPredicate: Option[FlussPredicate] =
+ if (tableInfo.getTableConfig.getLogFormat == LogFormat.ARROW)
pushedPredicate else None
+
override def createReaderFactory(): PartitionReaderFactory = {
if (isFallback) {
- new FlussAppendPartitionReaderFactory(tablePath, projection, None,
options, flussConfig)
+ new FlussAppendPartitionReaderFactory(
+ tablePath,
+ projection,
+ logTailPredicate,
+ options,
+ flussConfig)
} else {
new FlussLakePartitionReaderFactory(
tableInfo.getProperties.toMap,
tablePath,
projection,
+ pushedPredicate,
+ logTailPredicate,
flussConfig)
}
}
@@ -75,6 +88,7 @@ class FlussLakeAppendBatch(
val lakeSource =
FlussLakeUtils.createLakeSource(tableInfo.getProperties.toMap, tablePath)
lakeSource.withProject(FlussLakeUtils.lakeProjection(projection))
+ pushedPredicate.foreach(FlussLakeBatch.applyLakeFilters(lakeSource, _))
val lakeSplits = lakeSource
.createPlanner(new LakeSource.PlannerContext {
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeBatch.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeBatch.scala
index c07ac0b8b..d61aea9d1 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeBatch.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakeBatch.scala
@@ -19,13 +19,18 @@ package org.apache.fluss.spark.read.lake
import org.apache.fluss.client.initializer.{BucketOffsetsRetrieverImpl,
OffsetsInitializer}
import org.apache.fluss.config.Configuration
+import org.apache.fluss.lake.source.{LakeSource, LakeSplit}
import org.apache.fluss.metadata.{TableInfo, TablePath}
+import org.apache.fluss.predicate.{Predicate => FlussPredicate}
import org.apache.fluss.spark.read._
+import org.apache.spark.internal.Logging
import org.apache.spark.sql.connector.read.InputPartition
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import java.util.Collections
+
import scala.collection.JavaConverters._
abstract class FlussLakeBatch(
@@ -62,3 +67,21 @@ abstract class FlussLakeBatch(
.toMap
}
}
+
+object FlussLakeBatch extends Logging {
+
+ def applyLakeFilters(
+ lakeSource: LakeSource[LakeSplit],
+ predicates: java.util.List[FlussPredicate]):
LakeSource.FilterPushDownResult = {
+ val result = lakeSource.withFilters(predicates)
+ logInfo(
+ s"Lake source accepted ${result.acceptedPredicates()}, " +
+ s"remaining ${result.remainingPredicates()}")
+ result
+ }
+
+ def applyLakeFilters(
+ lakeSource: LakeSource[LakeSplit],
+ predicate: FlussPredicate): LakeSource.FilterPushDownResult =
+ applyLakeFilters(lakeSource, Collections.singletonList(predicate))
+}
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakePartitionReaderFactory.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakePartitionReaderFactory.scala
index 7acdb041f..369c45f74 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakePartitionReaderFactory.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/lake/FlussLakePartitionReaderFactory.scala
@@ -20,6 +20,7 @@ package org.apache.fluss.spark.read.lake
import org.apache.fluss.config.Configuration
import org.apache.fluss.lake.source.{LakeSource, LakeSplit}
import org.apache.fluss.metadata.TablePath
+import org.apache.fluss.predicate.{Predicate => FlussPredicate}
import org.apache.fluss.spark.read.{FlussAppendInputPartition,
FlussAppendPartitionReader}
import org.apache.spark.sql.catalyst.InternalRow
@@ -32,12 +33,15 @@ class FlussLakePartitionReaderFactory(
tableProperties: util.Map[String, String],
tablePath: TablePath,
projection: Array[Int],
+ flussPredicate: Option[FlussPredicate],
+ logTailPredicate: Option[FlussPredicate],
flussConfig: Configuration)
extends PartitionReaderFactory {
@transient private lazy val lakeSource: LakeSource[LakeSplit] = {
val source = FlussLakeUtils.createLakeSource(tableProperties, tablePath)
source.withProject(FlussLakeUtils.lakeProjection(projection))
+ flussPredicate.foreach(FlussLakeBatch.applyLakeFilters(source, _))
source
}
@@ -51,7 +55,12 @@ class FlussLakePartitionReaderFactory(
projection,
flussConfig)
case logSplit: FlussAppendInputPartition =>
- new FlussAppendPartitionReader(tablePath, projection, None, logSplit,
flussConfig)
+ new FlussAppendPartitionReader(
+ tablePath,
+ projection,
+ logTailPredicate,
+ logSplit,
+ flussConfig)
case mixedSplit: FlussLakeUpsertInputPartition =>
new FlussLakeUpsertPartitionReader(
tablePath,
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 ef8127f43..2f99e39a5 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
@@ -23,6 +23,7 @@ import org.apache.fluss.config.Configuration
import org.apache.fluss.exception.LakeTableSnapshotNotExistException
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.utils.ExceptionUtils
@@ -41,6 +42,7 @@ class FlussLakeUpsertBatch(
tablePath: TablePath,
tableInfo: TableInfo,
readSchema: StructType,
+ pushedPredicate: Option[FlussPredicate],
options: CaseInsensitiveStringMap,
flussConfig: Configuration)
extends FlussLakeBatch(tablePath, tableInfo, readSchema, options,
flussConfig) {
@@ -57,10 +59,13 @@ class FlussLakeUpsertBatch(
if (isFallback) {
new FlussUpsertPartitionReaderFactory(tablePath, projection, options,
flussConfig)
} else {
+ // PK kv-tail reader does not consume server-side log filters.
new FlussLakePartitionReaderFactory(
tableInfo.getProperties.toMap,
tablePath,
projection,
+ pushedPredicate,
+ None,
flussConfig)
}
}
@@ -83,6 +88,7 @@ class FlussLakeUpsertBatch(
val lakeSource =
FlussLakeUtils.createLakeSource(tableInfo.getProperties.toMap, tablePath)
lakeSource.withProject(FlussLakeUtils.lakeProjection(projection))
+ pushedPredicate.foreach(FlussLakeBatch.applyLakeFilters(lakeSource, _))
val lakeSplits = lakeSource
.createPlanner(() => lakeSnapshot.getSnapshotId)
diff --git
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPredicateConverter.scala
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPredicateConverter.scala
index fcf5f5b06..4ea5fd75f 100644
---
a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPredicateConverter.scala
+++
b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/utils/SparkPredicateConverter.scala
@@ -50,14 +50,21 @@ object SparkPredicateConverter {
def convertPredicates(
rowType: RowType,
predicates: Seq[Predicate]): (Option[FlussPredicate], Seq[Predicate]) = {
- val (accepted, converted) =
- predicates.flatMap(p => convert(rowType, p).map((p, _))).unzip
- val combined = converted match {
- case Seq() => None
- case Seq(single) => Some(single)
- case many => Some(PredicateBuilder.and(many.asJava))
- }
- (combined, accepted)
+ val pairs = convertPerPredicate(rowType, predicates)
+ (combineAnd(pairs.map(_._2)), pairs.map(_._1))
+ }
+
+ /** Returns the convertible (Spark, Fluss) predicate pairs in input order. */
+ def convertPerPredicate(
+ rowType: RowType,
+ predicates: Seq[Predicate]): Seq[(Predicate, FlussPredicate)] =
+ predicates.flatMap(p => convert(rowType, p).map((p, _)))
+
+ /** AND-combine a list of Fluss predicates; empty -> None, single -> as-is.
*/
+ def combineAnd(predicates: Seq[FlussPredicate]): Option[FlussPredicate] =
predicates match {
+ case Seq() => None
+ case Seq(single) => Some(single)
+ case many => Some(PredicateBuilder.and(many.asJava))
}
private def toFluss(
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 08ff77cd8..210b23e12 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
@@ -398,6 +398,131 @@ abstract class SparkLakeLogTableReadTest extends
SparkLakeTableReadTestBase {
}
}
+ test("Spark Lake Read: filter pushdown — lake-only (all data in lake)") {
+ withTable("t_pd_lake") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_lake (id INT, amount INT,
name STRING)
+ | 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_lake VALUES
+ |(1, 100, 'alpha'), (2, 200, 'beta'), (3, 300, 'gamma')
+ |""".stripMargin)
+ tierToLake("t_pd_lake")
+
+ val query =
+ sql(s"SELECT * FROM $DEFAULT_DATABASE.t_pd_lake WHERE amount > 150
ORDER BY id")
+ checkAnswer(query, Row(2, 200, "beta") :: Row(3, 300, "gamma") :: Nil)
+ assertPushedNames(query, Set(">"))
+ }
+ }
+
+ test("Spark Lake Read: filter pushdown — union (lake + log tail)") {
+ withTable("t_pd_union") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_union (id INT, amount INT,
name STRING)
+ | 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_union VALUES
+ |(1, 100, 'alpha'), (2, 200, 'beta'), (3, 300, 'gamma')
+ |""".stripMargin)
+ tierToLake("t_pd_union")
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_union VALUES
+ |(4, 400, 'delta'), (5, 500, 'epsilon')
+ |""".stripMargin)
+
+ val query =
+ sql(s"SELECT * FROM $DEFAULT_DATABASE.t_pd_union WHERE amount >= 200
ORDER BY id")
+ checkAnswer(
+ query,
+ Row(2, 200, "beta") ::
+ Row(3, 300, "gamma") ::
+ Row(4, 400, "delta") ::
+ Row(5, 500, "epsilon") :: Nil
+ )
+ assertPushedNames(query, Set(">="))
+ }
+ }
+
+ test("Spark Lake Read: filter pushdown — fallback (no lake snapshot)") {
+ withTable("t_pd_fallback") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_fallback (id INT, amount
INT, name STRING)
+ | 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_fallback VALUES
+ |(1, 100, 'alpha'), (2, 200, 'beta'), (3, 300, 'gamma')
+ |""".stripMargin)
+
+ val query =
+ sql(s"SELECT * FROM $DEFAULT_DATABASE.t_pd_fallback WHERE amount =
200")
+ checkAnswer(query, Row(2, 200, "beta") :: Nil)
+ assertPushedNames(query, Set("="))
+ }
+ }
+
+ test("Spark Lake Read: filter pushdown — partitioned lake table") {
+ withTable("t_pd_partitioned") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_partitioned
+ | (id INT, amount 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_partitioned VALUES
+ |(1, 100, 'alpha', '2026-01-01'),
+ |(2, 200, 'beta', '2026-01-01'),
+ |(3, 300, 'gamma', '2026-01-02')
+ |""".stripMargin)
+ tierToLake("t_pd_partitioned")
+
+ val query = sql(s"""
+ |SELECT id, amount, dt FROM
$DEFAULT_DATABASE.t_pd_partitioned
+ |WHERE amount >= 200 ORDER BY id""".stripMargin)
+ checkAnswer(query, Row(2, 200, "2026-01-01") :: Row(3, 300,
"2026-01-02") :: Nil)
+ assertPushedNames(query, Set(">="))
+ }
+ }
+
+ test("Spark Lake Read: filter pushdown — non-pushable predicate falls back
to Spark") {
+ withTable("t_pd_nonpushable") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_nonpushable (id INT, amount
INT, name STRING)
+ | 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_nonpushable VALUES
+ |(1, 100, 'alpha'), (2, 200, 'beta'), (3, 300, 'gamma'), (4, 400,
'delta')
+ |""".stripMargin)
+ tierToLake("t_pd_nonpushable")
+
+ val query =
+ sql(s"SELECT id FROM $DEFAULT_DATABASE.t_pd_nonpushable WHERE amount %
200 = 0 ORDER BY id")
+ checkAnswer(query, Row(2) :: Row(4) :: Nil)
+ // The modulo expression is not convertible; only the implicit
IS_NOT_NULL Spark adds is pushed.
+
assertResult(Set("IS_NOT_NULL"))(pushedPredicates(query).map(_.name()).toSet)
+ }
+ }
+
test("Spark Lake Read: non-FULL startup mode skips lake path") {
withTable("t_earliest") {
sql(s"""
@@ -427,6 +552,7 @@ abstract class SparkLakeLogTableReadTest extends
SparkLakeTableReadTestBase {
}
}
}
+
}
class SparkLakePaimonLogTableReadTest extends SparkLakeLogTableReadTest {
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 22277445c..4913ee29e 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
@@ -328,6 +328,56 @@ abstract class SparkLakePrimaryKeyTableReadTestBase
extends SparkLakeTableReadTe
}
}
+ test("Spark Lake Read: pk filter pushdown — lake-only on non-pk column") {
+ withTable("t_pd_pk_lake") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_pk_lake (id INT, name
STRING, score INT)
+ | TBLPROPERTIES (
+ | '${ConfigOptions.TABLE_DATALAKE_ENABLED.key()}' = true,
+ | '${ConfigOptions.TABLE_DATALAKE_FRESHNESS.key()}' = '1s',
+ | '${PRIMARY_KEY.key()}' = 'id',
+ | '${BUCKET_NUMBER.key()}' = 1)
+ |""".stripMargin)
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_pk_lake VALUES
+ |(1, 'alice', 90), (2, 'bob', 85), (3, 'charlie', 95), (4,
'dave', 70)
+ |""".stripMargin)
+ tierToLake("t_pd_pk_lake")
+
+ val query =
+ sql(s"SELECT * FROM $DEFAULT_DATABASE.t_pd_pk_lake WHERE score >= 90
ORDER BY id")
+ checkAnswer(query, Row(1, "alice", 90) :: Row(3, "charlie", 95) :: Nil)
+ assertPushedNames(query, Set(">="))
+ }
+ }
+
+ test("Spark Lake Read: pk filter pushdown — union (lake + kv tail)") {
+ withTable("t_pd_pk_union") {
+ sql(s"""
+ |CREATE TABLE $DEFAULT_DATABASE.t_pd_pk_union (id INT, name
STRING, score INT)
+ | TBLPROPERTIES (
+ | '${ConfigOptions.TABLE_DATALAKE_ENABLED.key()}' = true,
+ | '${ConfigOptions.TABLE_DATALAKE_FRESHNESS.key()}' = '1s',
+ | '${PRIMARY_KEY.key()}' = 'id',
+ | '${BUCKET_NUMBER.key()}' = 1)
+ |""".stripMargin)
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_pk_union VALUES
+ |(1, 'alice', 90), (2, 'bob', 85), (3, 'charlie', 95)
+ |""".stripMargin)
+ tierToLake("t_pd_pk_union")
+ sql(s"""
+ |INSERT INTO $DEFAULT_DATABASE.t_pd_pk_union VALUES
+ |(4, 'dave', 88), (5, 'eve', 92)
+ |""".stripMargin)
+
+ val query =
+ sql(s"SELECT id, score FROM $DEFAULT_DATABASE.t_pd_pk_union WHERE
score >= 90 ORDER BY id")
+ checkAnswer(query, Row(1, 90) :: Row(3, 95) :: Row(5, 92) :: Nil)
+ assertPushedNames(query, Set(">="))
+ }
+ }
+
test("Spark Lake Read: primary key table projection with type-dependent
columns") {
withTable("t") {
val tablePath = createTablePath("t")
@@ -381,6 +431,7 @@ abstract class SparkLakePrimaryKeyTableReadTestBase extends
SparkLakeTableReadTe
)
}
}
+
}
class SparkLakePaimonPrimaryKeyTableReadTest extends
SparkLakePrimaryKeyTableReadTestBase {
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 d3e4c743b..9ca8d81a0 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,9 +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.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.execution.datasources.v2.{BatchScanExec,
DataSourceV2ScanRelation}
import java.time.Duration
@@ -128,4 +132,23 @@ abstract class SparkLakeTableReadTestBase extends
FlussSparkTestBase {
}
}
+ protected def pushedPredicates(df: DataFrame): Array[Predicate] = {
+ val scans =
+ df.queryExecution.executedPlan.collect {
+ case b: BatchScanExec => b.scan
+ } ++ df.queryExecution.optimizedPlan.collect {
+ case DataSourceV2ScanRelation(_, scan, _, _, _) => scan
+ }
+ scans
+ .collect { case f: FlussScan => f.pushedSparkPredicates }
+ .flatten
+ .toArray
+ }
+
+ protected def assertPushedNames(df: DataFrame, expected: Set[String]): Unit
= {
+ val pushed = pushedPredicates(df).map(_.name()).toSet
+ assert(
+ expected.exists(pushed.contains),
+ s"Expected any of $expected in pushed predicates, got $pushed")
+ }
}