This is an automated email from the ASF dual-hosted git repository.

luoyuxia 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 b6d007942 [spark] Use kv snapshot in lake-batch fallback (#3317)
b6d007942 is described below

commit b6d00794288822cdab3ca503e9f7da18f2d2ff08
Author: Anton Borisov <[email protected]>
AuthorDate: Fri May 15 14:38:53 2026 +0100

    [spark] Use kv snapshot in lake-batch fallback (#3317)
---
 .../org/apache/fluss/spark/read/FlussBatch.scala   | 80 ++++++++++------------
 .../spark/read/lake/FlussLakeUpsertBatch.scala     | 32 ++-------
 .../SparkLakePrimaryKeyTableReadTestBase.scala     | 80 ++++++++++++++++++++++
 .../spark/lake/SparkLakeTableReadTestBase.scala    | 15 +++-
 4 files changed, 136 insertions(+), 71 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 128094a04..87f2fdad0 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
@@ -63,6 +63,40 @@ abstract class FlussBatch(
     }
   }
 
+  protected def createUpsertPartitions(
+      partitionName: String,
+      kvSnapshots: KvSnapshots,
+      bucketOffsetsRetriever: BucketOffsetsRetrieverImpl): 
Array[InputPartition] = {
+    val tableId = kvSnapshots.getTableId
+    val partitionId = kvSnapshots.getPartitionId
+    val bucketIds = kvSnapshots.getBucketIds
+    val bucketIdToLogOffset =
+      stoppingOffsetsInitializer.getBucketOffsets(partitionName, bucketIds, 
bucketOffsetsRetriever)
+    bucketIds.asScala
+      .map {
+        bucketId =>
+          val tableBucket = new TableBucket(tableId, partitionId, bucketId)
+          val snapshotIdOpt = kvSnapshots.getSnapshotId(bucketId)
+          val logStartingOffsetOpt = kvSnapshots.getLogOffset(bucketId)
+          val logEndingOffset = bucketIdToLogOffset.get(bucketId)
+
+          if (snapshotIdOpt.isPresent) {
+            assert(
+              logStartingOffsetOpt.isPresent,
+              "Log offset must be present when snapshot id is present")
+            FlussUpsertInputPartition(
+              tableBucket,
+              snapshotIdOpt.getAsLong,
+              logStartingOffsetOpt.getAsLong,
+              logEndingOffset)
+          } else {
+            FlussUpsertInputPartition(tableBucket, -1L, 
LogScanner.EARLIEST_OFFSET, logEndingOffset)
+          }
+      }
+      .map(_.asInstanceOf[InputPartition])
+      .toArray
+  }
+
   override def close(): Unit = {
     if (admin != null) {
       admin.close()
@@ -196,48 +230,6 @@ class FlussUpsertBatch(
   private val bucketOffsetsRetriever = new BucketOffsetsRetrieverImpl(admin, 
tablePath)
 
   override def planInputPartitions(): Array[InputPartition] = {
-    def createPartitions(partitionName: String, kvSnapshots: KvSnapshots): 
Array[InputPartition] = {
-      val tableId = kvSnapshots.getTableId
-      val partitionId = kvSnapshots.getPartitionId
-      val bucketIds = kvSnapshots.getBucketIds
-      val bucketIdToLogOffset =
-        stoppingOffsetsInitializer.getBucketOffsets(
-          partitionName,
-          bucketIds,
-          bucketOffsetsRetriever)
-      bucketIds.asScala
-        .map {
-          bucketId =>
-            val tableBucket = new TableBucket(tableId, partitionId, bucketId)
-            val snapshotIdOpt = kvSnapshots.getSnapshotId(bucketId)
-            val logStartingOffsetOpt = kvSnapshots.getLogOffset(bucketId)
-            val logEndingOffset = bucketIdToLogOffset.get(bucketId)
-
-            if (snapshotIdOpt.isPresent) {
-              assert(
-                logStartingOffsetOpt.isPresent,
-                "Log offset must be present when snapshot id is present")
-
-              // Create hybrid partition
-              FlussUpsertInputPartition(
-                tableBucket,
-                snapshotIdOpt.getAsLong,
-                logStartingOffsetOpt.getAsLong,
-                logEndingOffset
-              )
-            } else {
-              // No snapshot yet, only read log from beginning
-              FlussUpsertInputPartition(
-                tableBucket,
-                -1L,
-                LogScanner.EARLIEST_OFFSET,
-                logEndingOffset)
-            }
-        }
-        .map(_.asInstanceOf[InputPartition])
-        .toArray
-    }
-
     if (tableInfo.isPartitioned) {
       val matching =
         SparkPartitionPredicate.filterPartitions(partitionInfos.asScala.toSeq, 
partitionPredicate)
@@ -246,11 +238,11 @@ class FlussUpsertBatch(
           val partitionName = partitionInfo.getPartitionName
           val kvSnapshots =
             admin.getLatestKvSnapshots(tablePath, partitionName).get()
-          createPartitions(partitionName, kvSnapshots)
+          createUpsertPartitions(partitionName, kvSnapshots, 
bucketOffsetsRetriever)
       }.toArray
     } else {
       val kvSnapshots = admin.getLatestKvSnapshots(tablePath).get()
-      createPartitions(null, kvSnapshots)
+      createUpsertPartitions(null, kvSnapshots, bucketOffsetsRetriever)
     }
   }
 
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 2f99e39a5..2ef6ab770 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
@@ -267,38 +267,18 @@ class FlussLakeUpsertBatch(
   }
 
   private def planFallbackPartitions(): Array[InputPartition] = {
-    // Fallback to pure Fluss kv reading when no lake snapshot exists
     val bucketOffsetsRetriever = new BucketOffsetsRetrieverImpl(admin, 
tablePath)
-    val buckets = (0 until tableInfo.getNumBuckets).toSeq
-
-    def createPartitions(
-        partitionId: Option[Long],
-        partitionName: String): Array[InputPartition] = {
-      val stoppingOffsets =
-        getBucketOffsets(stoppingOffsetsInitializer, partitionName, buckets, 
bucketOffsetsRetriever)
-
-      buckets.map {
-        bucketId =>
-          val tableBucket = partitionId match {
-            case Some(pid) => new TableBucket(tableInfo.getTableId, pid, 
bucketId)
-            case None => new TableBucket(tableInfo.getTableId, bucketId)
-          }
-          // Use FlussUpsertInputPartition for fallback (reads from Fluss kv 
snapshot)
-          FlussUpsertInputPartition(
-            tableBucket,
-            -1L, // no snapshot
-            LogScanner.EARLIEST_OFFSET,
-            stoppingOffsets(bucketId)
-          ): InputPartition
-      }.toArray
-    }
 
     if (tableInfo.isPartitioned) {
       partitionInfos.asScala.flatMap {
-        pi => createPartitions(Some(pi.getPartitionId), pi.getPartitionName)
+        pi =>
+          val partitionName = pi.getPartitionName
+          val kvSnapshots = admin.getLatestKvSnapshots(tablePath, 
partitionName).get()
+          createUpsertPartitions(partitionName, kvSnapshots, 
bucketOffsetsRetriever)
       }.toArray
     } else {
-      createPartitions(None, null)
+      val kvSnapshots = admin.getLatestKvSnapshots(tablePath).get()
+      createUpsertPartitions(null, kvSnapshots, bucketOffsetsRetriever)
     }
   }
 }
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 4913ee29e..0de25f100 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
@@ -91,6 +91,86 @@ abstract class SparkLakePrimaryKeyTableReadTestBase extends 
SparkLakeTableReadTe
     }
   }
 
+  test("Spark Lake Read: pk table fallback uses Fluss kv snapshot for log tail 
merge") {
+    // Non-partitioned table
+    withTable("t_fb_hybrid") {
+      val tablePath = createTablePath("t_fb_hybrid")
+      sql(s"""
+             |CREATE TABLE $DEFAULT_DATABASE.t_fb_hybrid (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_fb_hybrid VALUES
+             |(1, "alice", 90), (2, "bob", 85), (3, "charlie", 95)
+             |""".stripMargin)
+
+      flussServer.triggerAndWaitSnapshot(tablePath)
+
+      sql(s"""
+             |INSERT INTO $DEFAULT_DATABASE.t_fb_hybrid VALUES
+             |(2, "bob_updated", 100), (4, "david", 88)
+             |""".stripMargin)
+
+      val df = sql(s"SELECT * FROM $DEFAULT_DATABASE.t_fb_hybrid ORDER BY id")
+      val partitions = lakeUpsertInputPartitions(df)
+      assert(
+        partitions.exists(_.snapshotId >= 0),
+        s"Expected at least one hybrid partition with snapshotId >= 0, got: 
${partitions.mkString(", ")}")
+      checkAnswer(
+        df,
+        Row(1, "alice", 90) :: Row(2, "bob_updated", 100) ::
+          Row(3, "charlie", 95) :: Row(4, "david", 88) :: Nil
+      )
+    }
+
+    // Partitioned table
+    withTable("t_fb_hybrid_partitioned") {
+      val tablePath = createTablePath("t_fb_hybrid_partitioned")
+      sql(s"""
+             |CREATE TABLE $DEFAULT_DATABASE.t_fb_hybrid_partitioned (id INT, 
name STRING, score INT, dt STRING)
+             | PARTITIONED BY (dt)
+             | TBLPROPERTIES (
+             |  '${ConfigOptions.TABLE_DATALAKE_ENABLED.key()}' = true,
+             |  '${ConfigOptions.TABLE_DATALAKE_FRESHNESS.key()}' = '1s',
+             |  '${PRIMARY_KEY.key()}' = 'id,dt',
+             |  '${BUCKET_NUMBER.key()}' = 1)
+             |""".stripMargin)
+
+      sql(s"""
+             |INSERT INTO $DEFAULT_DATABASE.t_fb_hybrid_partitioned VALUES
+             |(1, "alice", 90, "2026-01-01"),
+             |(2, "bob", 85, "2026-01-01"),
+             |(3, "charlie", 95, "2026-01-02")
+             |""".stripMargin)
+
+      flussServer.triggerAndWaitSnapshot(tablePath)
+
+      sql(s"""
+             |INSERT INTO $DEFAULT_DATABASE.t_fb_hybrid_partitioned VALUES
+             |(2, "bob_updated", 100, "2026-01-01"),
+             |(4, "david", 88, "2026-01-02")
+             |""".stripMargin)
+
+      val df = sql(s"SELECT * FROM $DEFAULT_DATABASE.t_fb_hybrid_partitioned 
ORDER BY id")
+      val partitions = lakeUpsertInputPartitions(df)
+      assert(
+        partitions.exists(_.snapshotId >= 0),
+        s"Expected at least one hybrid partition with snapshotId >= 0, got: 
${partitions.mkString(", ")}")
+      checkAnswer(
+        df,
+        Row(1, "alice", 90, "2026-01-01") ::
+          Row(2, "bob_updated", 100, "2026-01-01") ::
+          Row(3, "charlie", 95, "2026-01-02") ::
+          Row(4, "david", 88, "2026-01-02") :: Nil
+      )
+    }
+  }
+
   test("Spark Lake Read: pk table lake-only (all data in lake, no kv tail)") {
     // Test non-partitioned table
     withTable("t_lake_only") {
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 9ca8d81a0..526e89889 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,7 +22,7 @@ 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.fluss.spark.read.{FlussLakeUpsertScan, FlussScan, 
FlussUpsertInputPartition}
 
 import org.apache.flink.api.common.RuntimeExecutionMode
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment
@@ -151,4 +151,17 @@ abstract class SparkLakeTableReadTestBase extends 
FlussSparkTestBase {
       expected.exists(pushed.contains),
       s"Expected any of $expected in pushed predicates, got $pushed")
   }
+
+  protected def lakeUpsertInputPartitions(df: DataFrame): 
Array[FlussUpsertInputPartition] = {
+    val scans =
+      df.queryExecution.executedPlan.collect {
+        case b: BatchScanExec => b.scan
+      } ++ df.queryExecution.optimizedPlan.collect {
+        case DataSourceV2ScanRelation(_, scan, _, _, _) => scan
+      }
+    scans
+      .collect { case s: FlussLakeUpsertScan => s }
+      .flatMap(_.toBatch.planInputPartitions().collect { case p: 
FlussUpsertInputPartition => p })
+      .toArray
+  }
 }

Reply via email to