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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 483c96854a [spark] Advance streaming source past empty full snapshots 
(#9401)
483c96854a is described below

commit 483c96854a71a816d8b12013c7e0f958cff171b9
Author: sanshi <[email protected]>
AuthorDate: Thu Aug 27 15:25:43 2026 +0800

    [spark] Advance streaming source past empty full snapshots (#9401)
---
 .../spark/sources/PaimonMicroBatchStream.scala     |   6 +-
 .../paimon/spark/sources/PaimonSourceOffset.scala  |  15 ++-
 .../apache/paimon/spark/sources/StreamHelper.scala |  67 ++++++++--
 .../spark/sources/PaimonSourceOffsetTest.scala     |  30 +++++
 .../org/apache/paimon/spark/PaimonSourceTest.scala |  82 +++++++++++++
 .../sources/PaimonMicroBatchStreamITCase.scala     | 136 +++++++++++++++++++--
 .../spark/sources/PaimonMicroBatchStreamTest.scala |  19 +++
 7 files changed, 332 insertions(+), 23 deletions(-)

diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStream.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStream.scala
index c9133691c3..7fc7548c5e 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStream.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStream.scala
@@ -127,7 +127,9 @@ class PaimonMicroBatchStream(
   private def normalizeStartOffset(start: Offset): PaimonSourceOffset = {
     val startOffset = PaimonSourceOffset(start)
     val snapshotCompleted = startOffset.snapshotCompleted
-    val resumeSnapshotId = if (snapshotCompleted) {
+    val resumeSnapshotId = if (startOffset.emptySnapshotCompleted) {
+      startOffset.snapshotId
+    } else if (snapshotCompleted) {
       startOffset.snapshotId + 1
     } else {
       startOffset.snapshotId
@@ -220,6 +222,8 @@ class PaimonMicroBatchStream(
     consumerId.foreach {
       id =>
         offset.totalSplits match {
+          case Some(0L) if offset.emptySnapshotCompleted =>
+            notifyConsumerCheckpointComplete(offset.snapshotId)
           case Some(totalSplits) if offset.index >= totalSplits =>
             throw new IllegalStateException(
               s"Invalid Paimon source offset $offset: split index must be 
smaller than " +
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonSourceOffset.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonSourceOffset.scala
index d0311a35d9..b05e1e46e7 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonSourceOffset.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/PaimonSourceOffset.scala
@@ -49,7 +49,11 @@ case class PaimonSourceOffset(snapshotId: Long, index: Long, 
scanSnapshot: Boole
       index: Long = this.index,
       scanSnapshot: Boolean = this.scanSnapshot): PaimonSourceOffset = {
     val copied = PaimonSourceOffset(snapshotId, index, scanSnapshot)
-    if (snapshotId == this.snapshotId && scanSnapshot == this.scanSnapshot) {
+    if (
+      snapshotId == this.snapshotId &&
+      scanSnapshot == this.scanSnapshot &&
+      totalSplitsValue.forall(_ > 0 || index == 
PaimonSourceOffset.INIT_OFFSET_INDEX)
+    ) {
       copied.totalSplitsValue = totalSplitsValue
     }
     copied
@@ -59,6 +63,9 @@ case class PaimonSourceOffset(snapshotId: Long, index: Long, 
scanSnapshot: Boole
     totalSplits.exists(index == _ - 1)
   }
 
+  /** Whether this offset is the cursor immediately after an empty full 
snapshot. */
+  private[spark] def emptySnapshotCompleted: Boolean = totalSplits.contains(0L)
+
   override def json(): String = {
     val node = JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.createObjectNode()
     node.put(PaimonSourceOffset.FIELD_SNAPSHOT_ID, snapshotId)
@@ -103,7 +110,11 @@ object PaimonSourceOffset {
       index: Long,
       scanSnapshot: Boolean,
       totalSplits: Long): PaimonSourceOffset = {
-    require(totalSplits > 0, s"Total splits must be positive, but was 
$totalSplits.")
+    require(
+      totalSplits > 0 ||
+        (totalSplits == 0 && index == INIT_OFFSET_INDEX && !scanSnapshot),
+      s"Total splits must be positive except for an empty full snapshot 
cursor, but was $totalSplits."
+    )
     val offset = PaimonSourceOffset(snapshotId, index, scanSnapshot)
     offset.totalSplitsValue = Some(totalSplits)
     offset
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/StreamHelper.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/StreamHelper.scala
index 68272d32f0..c00673009f 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/StreamHelper.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/sources/StreamHelper.scala
@@ -22,7 +22,7 @@ import org.apache.paimon.CoreOptions
 import org.apache.paimon.data.BinaryRow
 import org.apache.paimon.spark.SparkTypeUtils
 import org.apache.paimon.table.DataTable
-import org.apache.paimon.table.source.{DataSplit, StreamDataTableScan}
+import org.apache.paimon.table.source.{DataSplit, SnapshotNotExistPlan, 
StreamDataTableScan}
 import org.apache.paimon.table.source.TableScan.Plan
 import org.apache.paimon.table.source.snapshot.StartingContext
 import org.apache.paimon.utils.{InternalRowPartitionComputer, TypeUtils}
@@ -49,6 +49,10 @@ case class IndexedDataSplit(snapshotId: Long, index: Long, 
entry: DataSplit) {
   }
 }
 
+private case class BatchResult(
+    indexedDataSplits: Array[IndexedDataSplit],
+    emptyFullSnapshotNextId: Option[Long])
+
 private[spark] trait StreamHelper {
 
   def table: DataTable
@@ -85,29 +89,54 @@ private[spark] trait StreamHelper {
       startOffset: PaimonSourceOffset,
       endOffset: Option[PaimonSourceOffset],
       limit: ReadLimit): Option[PaimonSourceOffset] = {
-    val indexedDataSplits = getBatch(startOffset, endOffset, Some(limit))
-    indexedDataSplits.lastOption
+    val batchResult = getBatchResult(startOffset, endOffset, Some(limit))
+    batchResult.indexedDataSplits.lastOption
       .map {
         ids =>
           val scanSnapshot =
             startOffset.scanSnapshot && 
ids.snapshotId.equals(startOffset.snapshotId)
           if (includeSnapshotCompletionInOffset) {
-            val totalSplits = ids.totalSplits.getOrElse(
-              throw new IllegalStateException(
-                s"Missing total splits for snapshot ${ids.snapshotId}."))
+            val totalSplits = ids.totalSplits.getOrElse(throw new 
IllegalStateException(
+              s"Missing total splits for snapshot ${ids.snapshotId}."))
             PaimonSourceOffset.withTotalSplits(ids.snapshotId, ids.index, 
scanSnapshot, totalSplits)
           } else {
             PaimonSourceOffset(ids.snapshotId, ids.index, scanSnapshot)
           }
       }
+      .orElse {
+        batchResult.emptyFullSnapshotNextId.map {
+          nextSnapshotId =>
+            if (includeSnapshotCompletionInOffset) {
+              PaimonSourceOffset.withTotalSplits(
+                nextSnapshotId,
+                PaimonSourceOffset.INIT_OFFSET_INDEX,
+                scanSnapshot = false,
+                totalSplits = 0L)
+            } else {
+              PaimonSourceOffset(
+                nextSnapshotId,
+                PaimonSourceOffset.INIT_OFFSET_INDEX,
+                scanSnapshot = false)
+            }
+        }
+      }
   }
 
   def getBatch(
       startOffset: PaimonSourceOffset,
       endOffset: Option[PaimonSourceOffset],
       limit: Option[ReadLimit]): Array[IndexedDataSplit] = {
+    getBatchResult(startOffset, endOffset, limit).indexedDataSplits
+  }
+
+  private def getBatchResult(
+      startOffset: PaimonSourceOffset,
+      endOffset: Option[PaimonSourceOffset],
+      limit: Option[ReadLimit]): BatchResult = {
     if (startOffset != null) {
-      if (startOffset.snapshotCompleted) {
+      if (startOffset.emptySnapshotCompleted) {
+        streamScan.restore(startOffset.snapshotId, false)
+      } else if (startOffset.snapshotCompleted) {
         streamScan.restore(startOffset.snapshotId + 1, false)
       } else {
         streamScan.restore(startOffset.snapshotId, startOffset.scanSnapshot)
@@ -116,6 +145,9 @@ private[spark] trait StreamHelper {
 
     val readLimitGuard = limit.flatMap(PaimonReadLimits(_, lastTriggerMillis))
     var hasSplits = true
+    var hasNonEmptyPlan = false
+    var firstPlan = true
+    var emptyFullSnapshotNextId: Option[Long] = None
     def continue: Boolean = {
       hasSplits && readLimitGuard.forall(_.hasCapacity) && endOffset.forall(
         streamScan.checkpoint() <= _.snapshotId)
@@ -125,22 +157,35 @@ private[spark] trait StreamHelper {
     while (continue) {
       val plan = streamScan.plan()
       if (plan.splits.isEmpty) {
-        hasSplits = false
+        val isCompletedEmptyFullSnapshot =
+          firstPlan &&
+            startOffset != null &&
+            startOffset.scanSnapshot &&
+            (plan ne SnapshotNotExistPlan.INSTANCE)
+        if (isCompletedEmptyFullSnapshot) {
+          Option(streamScan.checkpoint()).foreach {
+            nextSnapshotId => emptyFullSnapshotNextId = Some(nextSnapshotId)
+          }
+        } else {
+          hasSplits = false
+        }
       } else {
+        hasNonEmptyPlan = true
         indexedDataSplits ++= convertPlanToIndexedSplits(plan)
           // Filter by (start, end]
           .filter(ids => inRange(ids, startOffset, endOffset))
           // Filter splits by read limits other than ReadMinRows.
           .takeWhile(s => readLimitGuard.forall(_.admit(s)))
       }
+      firstPlan = false
     }
 
     // Filter splits by ReadMinRows read limit if exists.
     // If this batch doesn't meet the condition of ReadMinRows, then nothing 
will be returned.
-    if (readLimitGuard.exists(_.skipBatch)) {
-      Array.empty
+    if (readLimitGuard.exists(_.skipBatch) && hasNonEmptyPlan) {
+      BatchResult(Array.empty, None)
     } else {
-      indexedDataSplits.toArray
+      BatchResult(indexedDataSplits.toArray, emptyFullSnapshotNextId)
     }
   }
 
diff --git 
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/sources/PaimonSourceOffsetTest.scala
 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/sources/PaimonSourceOffsetTest.scala
index 0e4e901942..739443bb40 100644
--- 
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/sources/PaimonSourceOffsetTest.scala
+++ 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/sources/PaimonSourceOffsetTest.scala
@@ -35,6 +35,36 @@ class PaimonSourceOffsetTest extends AnyFunSuite {
     assert(restored.totalSplits.contains(2L))
   }
 
+  test("round trip an empty snapshot cursor") {
+    val offset = PaimonSourceOffset.withTotalSplits(
+      snapshotId = 4L,
+      index = PaimonSourceOffset.INIT_OFFSET_INDEX,
+      scanSnapshot = false,
+      totalSplits = 0L)
+
+    val restored = PaimonSourceOffset(offset.json())
+
+    assert(restored.emptySnapshotCompleted)
+    assert(restored.snapshotId == 4L)
+    assert(restored.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+    assert(!restored.scanSnapshot)
+  }
+
+  test("copying an empty snapshot cursor with a different index clears its 
marker") {
+    val offset = PaimonSourceOffset.withTotalSplits(
+      snapshotId = 4L,
+      index = PaimonSourceOffset.INIT_OFFSET_INDEX,
+      scanSnapshot = false,
+      totalSplits = 0L)
+
+    val copied = offset.copy(index = 0L)
+    val restored = PaimonSourceOffset(copied.json())
+
+    assert(copied.index == 0L)
+    assert(copied.totalSplits.isEmpty)
+    assert(restored.totalSplits.isEmpty)
+  }
+
   test("copy and Java serialization preserve total splits") {
     val offset = offsetWithTotalSplits(scanSnapshot = true)
 
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSourceTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSourceTest.scala
index 624c2c9dc7..cd6aa554f7 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSourceTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSourceTest.scala
@@ -537,6 +537,88 @@ class PaimonSourceTest extends PaimonSparkTestBase with 
StreamTest {
     }
   }
 
+  test("Paimon Source: advance past an empty initial full snapshot") {
+    withTempDir {
+      checkpointDir =>
+        spark.sql("""
+                    |CREATE TABLE T (a INT, b STRING)
+                    |TBLPROPERTIES ('primary-key'='a', 'bucket'='2', 
'file.format'='parquet')
+                    |""".stripMargin)
+        val location = loadTable("T").location().toString
+        spark.sql("INSERT INTO T VALUES (1, 'before')")
+        spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+
+        val query = spark.readStream
+          .format("paimon")
+          .load(location)
+          .writeStream
+          .format("memory")
+          .option("checkpointLocation", checkpointDir.getCanonicalPath)
+          .queryName("empty_full_snapshot")
+          .outputMode("append")
+          .start()
+
+        try {
+          query.processAllAvailable()
+          checkAnswer(spark.sql("SELECT * FROM empty_full_snapshot"), 
Seq.empty)
+
+          spark.sql("INSERT INTO T VALUES (2, 'after')")
+          query.processAllAvailable()
+          checkAnswer(spark.sql("SELECT * FROM empty_full_snapshot"), 
Seq(Row(2, "after")))
+        } finally {
+          query.stop()
+        }
+    }
+  }
+
+  test("Paimon Source: resume past an empty initial full snapshot after 
restart") {
+    withTempDir {
+      checkpointDir =>
+        spark.sql("DROP TABLE IF EXISTS T")
+        spark.sql("""
+                    |CREATE TABLE T (a INT, b STRING)
+                    |TBLPROPERTIES ('primary-key'='a', 'bucket'='2', 
'file.format'='parquet')
+                    |""".stripMargin)
+        val location = loadTable("T").location().toString
+        spark.sql("INSERT INTO T VALUES (1, 'before')")
+        spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+
+        val targetLocation = prepareTableAndGetLocation(0, false, tableName = 
"T2").location
+
+        val df = spark.readStream
+          .format("paimon")
+          .load(location)
+          .writeStream
+          .format("paimon")
+          .option("checkpointLocation", checkpointDir.getCanonicalPath)
+
+        val emptySnapshotId = 
loadTable("T").snapshotManager().latestSnapshotId()
+        val firstQuery = df.start(targetLocation)
+        try {
+          firstQuery.processAllAvailable()
+          checkAnswer(spark.sql("SELECT * FROM T2"), Seq.empty)
+          val endOffset = 
PaimonSourceOffset(firstQuery.lastProgress.sources(0).endOffset)
+          assert(endOffset.snapshotId == emptySnapshotId + 1L)
+          assert(endOffset.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+          assert(!endOffset.scanSnapshot)
+          assert(endOffset.totalSplits.isEmpty)
+        } finally {
+          firstQuery.stop()
+        }
+
+        spark.sql("INSERT INTO T VALUES (2, 'after')")
+
+        val restartedQuery = df.start(targetLocation)
+
+        try {
+          restartedQuery.processAllAvailable()
+          checkAnswer(spark.sql("SELECT * FROM T2"), Seq(Row(2, "after")))
+        } finally {
+          restartedQuery.stop()
+        }
+    }
+  }
+
   test("Paimon Source: from-snapshot and from-snapshot-full scan mode") {
     withTempDirs {
       (checkpointDir1, checkpointDir2) =>
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamITCase.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamITCase.scala
index c014a25474..ca3add928b 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamITCase.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamITCase.scala
@@ -24,6 +24,7 @@ import org.apache.paimon.spark.PaimonSparkTestBase
 import org.apache.paimon.table.FileStoreTable
 import org.apache.paimon.table.source.OutOfRangeException
 
+import org.apache.spark.sql.Row
 import org.apache.spark.sql.connector.read.streaming.ReadLimit
 
 import java.util.{Collections, HashMap}
@@ -54,6 +55,79 @@ class PaimonMicroBatchStreamITCase extends 
PaimonSparkTestBase {
     assert(!latest.json().contains("totalSplits"))
   }
 
+  test("advance past an empty initial full snapshot without a consumer") {
+    val sourceTable = createTableWithOneSnapshot()
+    spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val emptySnapshotId = sourceTable.snapshotManager().latestSnapshotId()
+    assert(initial.snapshotId == emptySnapshotId)
+    assert(initial.scanSnapshot)
+
+    stream.lastTriggerMillis = System.currentTimeMillis()
+    val emptyEnd = latestOffset(stream, initial, ReadLimit.minRows(100L, 
60000L))
+    assert(emptyEnd.snapshotId == emptySnapshotId + 1L)
+    assert(emptyEnd.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+    assert(!emptyEnd.scanSnapshot)
+
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+    val nextEnd = latestOffset(stream, emptyEnd, ReadLimit.allAvailable())
+
+    assert(nextEnd.snapshotId == emptyEnd.snapshotId)
+    assert(!nextEnd.scanSnapshot)
+    assert(stream.planInputPartitions(emptyEnd, nextEnd).nonEmpty)
+  }
+
+  test("advance consumer past an empty initial full snapshot") {
+    val sourceTable = createTableWithOneSnapshot()
+    spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+    val sourceTableWithConsumer = withConsumer(sourceTable)
+
+    val stream = createStream(sourceTableWithConsumer)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val emptySnapshotId = sourceTable.snapshotManager().latestSnapshotId()
+    val emptyEnd = latestOffset(stream, initial, ReadLimit.allAvailable())
+
+    assert(emptyEnd.snapshotId == emptySnapshotId + 1L)
+    assert(emptyEnd.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+    assert(!emptyEnd.scanSnapshot)
+    assert(emptyEnd.emptySnapshotCompleted)
+
+    stream.commit(emptyEnd)
+    assert(consumerNextSnapshot(sourceTableWithConsumer) == emptySnapshotId + 
1L)
+  }
+
+  test("do not advance an empty full snapshot past a delta deferred by 
ReadMinRows") {
+    val sourceTable = createTableWithOneSnapshot()
+    spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+    stream.lastTriggerMillis = System.currentTimeMillis()
+
+    val deferred = stream.latestOffset(initial, ReadLimit.minRows(100L, 
60000L))
+
+    assert(deferred == null)
+  }
+
+  test("resume an empty full snapshot after ReadMinRows delay") {
+    val sourceTable = createTableWithOneSnapshot()
+    spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+
+    stream.lastTriggerMillis = System.currentTimeMillis()
+    assert(stream.latestOffset(initial, ReadLimit.minRows(100L, 60000L)) == 
null)
+
+    stream.lastTriggerMillis = System.currentTimeMillis() - 60001L
+    val resumed = latestOffset(stream, initial, ReadLimit.minRows(100L, 
60000L))
+    assert(resumed.snapshotId == 
sourceTable.snapshotManager().latestSnapshotId())
+    assert(!resumed.scanSnapshot)
+    assert(stream.planInputPartitions(initial, resumed).nonEmpty)
+  }
+
   test("create consumer only after the initial full snapshot is completely 
consumed") {
     val sourceTable = withConsumer(createTableWithOneSnapshot())
     val stream = createStream(sourceTable)
@@ -289,21 +363,65 @@ class PaimonMicroBatchStreamITCase extends 
PaimonSparkTestBase {
     }
   }
 
+  test("Spark query restarts after an empty initial full snapshot with a 
consumer") {
+    withTempDir {
+      checkpointDir =>
+        val sourceTable = createTableWithOneSnapshot()
+        spark.sql("INSERT OVERWRITE T SELECT * FROM T WHERE false")
+        val location = sourceTable.location().toString
+        val emptySnapshotId = sourceTable.snapshotManager().latestSnapshotId()
+        val targetTable = createTableWithoutSnapshot("T2")
+        val targetLocation = targetTable.location().toString
+
+        val df = spark.readStream
+          .format("paimon")
+          .option(CoreOptions.CONSUMER_ID.key(), consumerId)
+          .load(location)
+          .writeStream
+          .format("paimon")
+          .option("checkpointLocation", checkpointDir.getCanonicalPath)
+
+        val firstQuery = df.start(targetLocation)
+        try {
+          firstQuery.processAllAvailable()
+          checkAnswer(spark.sql("SELECT * FROM T2"), Seq.empty)
+          val endOffset = 
PaimonSourceOffset(firstQuery.lastProgress.sources(0).endOffset)
+          assert(endOffset.snapshotId == emptySnapshotId + 1L)
+          assert(endOffset.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+          assert(!endOffset.scanSnapshot)
+          assert(endOffset.emptySnapshotCompleted)
+        } finally {
+          firstQuery.stop()
+        }
+
+        spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+
+        val restartedQuery = df.start(targetLocation)
+
+        try {
+          restartedQuery.processAllAvailable()
+          checkAnswer(spark.sql("SELECT * FROM T2"), Seq(Row(20, "v_20")))
+        } finally {
+          restartedQuery.stop()
+        }
+    }
+  }
+
   private def createTableWithOneSnapshot(): FileStoreTable = {
     createTableWithoutSnapshot()
     spark.sql("INSERT INTO T VALUES (10, 'v_10'), (11, 'v_11'), (12, 'v_12')")
     loadTable("T")
   }
 
-  private def createTableWithoutSnapshot(): FileStoreTable = {
-    spark.sql("DROP TABLE IF EXISTS T")
-    spark.sql("""CREATE TABLE T (a INT, b STRING)
-                |TBLPROPERTIES (
-                |  'bucket' = '2',
-                |  'bucket-key' = 'a',
-                |  'file.format' = 'parquet'
-                |)""".stripMargin)
-    loadTable("T")
+  private def createTableWithoutSnapshot(tableName: String = "T"): 
FileStoreTable = {
+    spark.sql(s"DROP TABLE IF EXISTS $tableName")
+    spark.sql(s"""CREATE TABLE $tableName (a INT, b STRING)
+                 |TBLPROPERTIES (
+                 |  'bucket' = '2',
+                 |  'bucket-key' = 'a',
+                 |  'file.format' = 'parquet'
+                 |)""".stripMargin)
+    loadTable(tableName)
   }
 
   private def withConsumer(table: FileStoreTable): FileStoreTable = {
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamTest.scala
index 4f605d71cc..d4c6965a22 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamTest.scala
@@ -68,6 +68,25 @@ class PaimonMicroBatchStreamTest extends AnyFunSuite {
     verify(scan, times(2)).notifyCheckpointComplete(6L)
   }
 
+  test("propagate empty snapshot consumer update failure and allow retry") {
+    val (stream, scan) = createStreamWithConsumer()
+    val empty = PaimonSourceOffset.withTotalSplits(
+      snapshotId = 6L,
+      index = PaimonSourceOffset.INIT_OFFSET_INDEX,
+      scanSnapshot = false,
+      totalSplits = 0L)
+    val failure = new UncheckedIOException(new IOException("expected failure"))
+    doThrow(failure).doNothing().when(scan).notifyCheckpointComplete(6L)
+
+    val thrown = intercept[UncheckedIOException] {
+      stream.commit(empty)
+    }
+    assert(thrown eq failure)
+
+    stream.commit(empty)
+    verify(scan, times(2)).notifyCheckpointComplete(6L)
+  }
+
   private def consumerOffset(index: Long, totalSplits: Long): 
PaimonSourceOffset = {
     PaimonSourceOffset.withTotalSplits(
       snapshotId = 5L,

Reply via email to