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 a468f669f7 [spark] Support consumer progress in streaming source 
(#9273)
a468f669f7 is described below

commit a468f669f75171bb476d98fdaa5cf740ed569986
Author: LsomeYeah <[email protected]>
AuthorDate: Thu Aug 20 10:11:16 2026 +0800

    [spark] Support consumer progress in streaming source (#9273)
---
 docs/docs/spark/structured-streaming.md            |  90 ++++++
 .../spark/sources/PaimonMicroBatchStream.scala     | 124 ++++++--
 .../paimon/spark/sources/PaimonSourceOffset.scala  |  47 ++-
 .../apache/paimon/spark/sources/StreamHelper.scala |  52 +++-
 .../spark/sources/PaimonSourceOffsetTest.scala     | 135 ++++++++
 .../sources/PaimonMicroBatchStreamITCase.scala     | 339 +++++++++++++++++++++
 .../spark/sources/PaimonMicroBatchStreamTest.scala |  89 ++++++
 7 files changed, 837 insertions(+), 39 deletions(-)

diff --git a/docs/docs/spark/structured-streaming.md 
b/docs/docs/spark/structured-streaming.md
index 41c0a19dbf..bee6d80aa3 100644
--- a/docs/docs/spark/structured-streaming.md
+++ b/docs/docs/spark/structured-streaming.md
@@ -115,6 +115,96 @@ val query = spark.readStream
   .start()
 ```
 
+### Consumer progress
+
+You can assign a Paimon Consumer to a streaming query. The Consumer records a
+table-side, snapshot-level recovery position and acts as a fence during normal
+snapshot expiration. For tables with a decoupled changelog lifecycle, enabling
+`consumer.changelog-only` makes the Consumer protect long-lived changelogs
+instead of snapshots.
+
+Configure the Consumer lifetime as a table property before starting the query:
+
+```sql
+ALTER TABLE table_name SET TBLPROPERTIES (
+  'consumer.expiration-time' = '1 d'
+);
+```
+
+```scala
+val query = spark.readStream
+  .format("paimon")
+  .option("consumer-id", "my-consumer")
+  .table("table_name")
+  .writeStream
+  .format("console")
+  .option("checkpointLocation", "/path/to/spark/checkpoint")
+  .start()
+```
+
+Spark checkpoint and Paimon Consumer progress have different granularities:
+
+- When the Spark checkpoint is available, Spark uses it for precise micro-batch
+  recovery.
+- A new query without that checkpoint starts from the Consumer position. This
+  recovery is conservative and may replay a completed snapshot if the query
+  failed after processing it but before updating the Consumer.
+
+Paimon updates the Consumer only from Spark's successful micro-batch commit
+callback. When a micro-batch ends partway through a delta snapshot, the 
Consumer
+may be created or refreshed at that snapshot so that the whole snapshot remains
+protected and can be replayed. The Consumer advances past a snapshot only after
+the micro-batch containing its last split is committed. A Consumer update 
failure
+is propagated, fails the running query, and leaves the Consumer at its previous
+conservative position.
+
+Spark never advances Consumer progress past an incomplete snapshot. An 
incomplete
+initial full snapshot does not create a Consumer because Consumer recovery from
+that snapshot would use a delta scan. `consumer.mode` does not select a 
different
+Spark source implementation.
+
+Offsets restored from an older Spark checkpoint do not contain snapshot
+completion metadata. Spark logs a warning and leaves the Consumer unchanged for
+those offsets rather than advancing it without proof that the snapshot 
finished.
+
+If the Consumer does not exist, the configured startup options are used. For
+example, the default `latest-full` mode first reads the full snapshot and 
creates
+the Consumer only after that snapshot is completely committed. If the Consumer
+already exists, its next snapshot takes precedence over startup options and is
+read incrementally, unless `consumer.ignore-progress` is enabled.
+
+:::note
+
+Spark can invoke the source commit callback while constructing a later
+micro-batch. Therefore the Consumer may temporarily lag behind the latest
+successful batch, especially while a query is idle or after its final available
+batch. This is safe and can only cause conservative replay.
+
+:::
+
+Consumer files are scoped to the branch being read. Use one active Spark query
+for each `(table, branch, consumer-id)` combination. Concurrent queries sharing
+the same Consumer are unsupported because a faster query could move the shared
+position past data still needed by a slower query.
+
+Configure `consumer.expiration-time` according to the longest expected snapshot
+processing time, failure recovery time, and idle interval. Eligible Spark 
source
+commit callbacks refresh the Consumer file, but there is no separate 
timer-based
+Spark heartbeat. Expired Consumer files are cleaned by non-write-only table
+commits. A write-only writer does not perform Consumer expiration, so stale
+Consumer IDs must be managed explicitly or by a separate non-write-only
+maintenance process.
+
+An initial full scan has no Consumer retention fence until it completes, so
+snapshot retention should be long enough for that scan. If its snapshot expires
+first, a restart without a Spark checkpoint performs a new initial full scan
+according to the startup options.
+
+When expired data would force recovery of a logged Spark batch to switch 
between
+a full and an incremental scan, Paimon fails the query instead of combining the
+two plans. Start a new query with a new checkpoint to apply the startup options
+again.
+
 Paimon Structured Streaming also supports a variety of streaming read modes, 
it can support many triggers and many read limits.
 
 These read limits are supported:
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 96883c04c6..c9133691c3 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
@@ -23,7 +23,7 @@ import org.apache.paimon.options.Options
 import org.apache.paimon.schema.TableSchema
 import org.apache.paimon.spark.{PaimonImplicits, 
PaimonMicroBatchInputPartition, PaimonMicroBatchMetadata, 
PaimonPartitionReaderFactory, SparkConnectorOptions}
 import org.apache.paimon.table.DataTable
-import org.apache.paimon.table.source.{DataSplit, ReadBuilder}
+import org.apache.paimon.table.source.{DataSplit, OutOfRangeException, 
ReadBuilder}
 import org.apache.paimon.utils.DataEvolutionUtils
 
 import org.apache.spark.internal.Logging
@@ -47,13 +47,18 @@ class PaimonMicroBatchStream(
   with Logging {
 
   private val options = Options.fromMap(table.options())
+  private val coreOptions = new CoreOptions(options)
+  private val consumerId = Option(coreOptions.consumerId())
+  private val warnedLegacyConsumerSnapshots = mutable.Set.empty[Long]
+
+  override protected def includeSnapshotCompletionInOffset: Boolean = 
consumerId.isDefined
 
   lazy val initOffset: PaimonSourceOffset = {
-    val initSnapshotId = Math.max(
-      table.snapshotManager().earliestSnapshotId(),
-      streamScanStartingContext.getSnapshotId)
-    val scanSnapshot = if (initSnapshotId == 
streamScanStartingContext.getSnapshotId) {
-      streamScanStartingContext.getScanFullSnapshot.booleanValue()
+    val startingSnapshotId = streamScanStartingContext.getSnapshotId
+    val startingScanSnapshot = 
streamScanStartingContext.getScanFullSnapshot.booleanValue()
+    val initSnapshotId = Math.max(earliestReadableId(startingScanSnapshot), 
startingSnapshotId)
+    val scanSnapshot = if (initSnapshotId == startingSnapshotId) {
+      startingScanSnapshot
     } else {
       false
     }
@@ -119,8 +124,35 @@ class PaimonMicroBatchStream(
       "That latestOffset(Offset, ReadLimit) method should be called instead of 
this method.")
   }
 
-  override def latestOffset(start: Offset, limit: ReadLimit): Offset = {
+  private def normalizeStartOffset(start: Offset): PaimonSourceOffset = {
     val startOffset = PaimonSourceOffset(start)
+    val snapshotCompleted = startOffset.snapshotCompleted
+    val resumeSnapshotId = if (snapshotCompleted) {
+      startOffset.snapshotId + 1
+    } else {
+      startOffset.snapshotId
+    }
+    val resumeScanSnapshot = !snapshotCompleted && startOffset.scanSnapshot
+    val earliestReadable = earliestReadableId(resumeScanSnapshot)
+    // Fall back to initOffset only when the checkpointed resume position has 
expired.
+    // initOffset is recomputed from the current table state on every 
(re)start,
+    // so with scan modes like latest-full it points at the current snapshot 
with
+    // scanSnapshot=true. Clamping a still-valid checkpointed offset up to it 
made
+    // a restarted query silently skip the changelog gap and re-scan the whole
+    // snapshot, re-emitting every row as +I.
+    if (resumeSnapshotId < earliestReadable) {
+      logWarning(
+        s"Checkpointed start offset $startOffset is no longer available " +
+          s"(earliest readable snapshot or changelog: $earliestReadable), " +
+          s"attempting recovery from $initOffset.")
+      initOffset
+    } else {
+      startOffset
+    }
+  }
+
+  override def latestOffset(start: Offset, limit: ReadLimit): Offset = {
+    val startOffset = normalizeStartOffset(start)
     getLatestOffset(startOffset, offsetForTriggerAvailableNow, limit).map {
       offset =>
         lastTriggerMillis = System.currentTimeMillis()
@@ -129,25 +161,23 @@ class PaimonMicroBatchStream(
   }
 
   override def planInputPartitions(start: Offset, end: Offset): 
Array[InputPartition] = {
-    val startOffset = {
-      val startOffset0 = PaimonSourceOffset(start)
-      // Fall back to initOffset only when the checkpointed snapshot has 
expired.
-      // initOffset is recomputed from the current table state on every 
(re)start,
-      // so with scan modes like latest-full it points at the current snapshot 
with
-      // scanSnapshot=true. Clamping a still-valid checkpointed offset up to 
it made
-      // a restarted query silently skip the changelog gap and re-scan the 
whole
-      // snapshot, re-emitting every row as +I.
-      if (startOffset0.snapshotId < 
table.snapshotManager().earliestSnapshotId()) {
-        logWarning(
-          s"Checkpointed start offset $startOffset0 is no longer available " +
-            s"(earliest snapshot: 
${table.snapshotManager().earliestSnapshotId()}), " +
-            s"falling back to $initOffset.")
-        initOffset
-      } else {
-        startOffset0
-      }
-    }
+    val startOffset = normalizeStartOffset(start)
     val endOffset = PaimonSourceOffset(end)
+    if (
+      startOffset.snapshotId == endOffset.snapshotId &&
+      startOffset.scanSnapshot != endOffset.scanSnapshot
+    ) {
+      throw new OutOfRangeException(
+        s"Cannot plan Paimon micro-batch because normalized start offset 
$startOffset and " +
+          s"logged end offset $endOffset use different scan modes. The 
checkpointed range " +
+          "is no longer readable without changing its data.")
+    }
+    if (startOffset.compareTo(endOffset) > 0) {
+      throw new OutOfRangeException(
+        s"Cannot plan Paimon micro-batch because normalized start offset 
$startOffset is " +
+          s"newer than logged end offset $endOffset. The data needed to replay 
the logged " +
+          "range is no longer readable.")
+    }
 
     val admittedSplits = getBatch(startOffset, Some(endOffset), None)
     val metadata = createMicroBatchMetadata(startOffset, endOffset, 
admittedSplits)
@@ -186,7 +216,34 @@ class PaimonMicroBatchStream(
   }
 
   override def commit(end: Offset): Unit = {
-    committedOffset = Some(PaimonSourceOffset(end))
+    val offset = PaimonSourceOffset(end)
+    consumerId.foreach {
+      id =>
+        offset.totalSplits match {
+          case Some(totalSplits) if offset.index >= totalSplits =>
+            throw new IllegalStateException(
+              s"Invalid Paimon source offset $offset: split index must be 
smaller than " +
+                s"totalSplits ($totalSplits).")
+          case Some(_) if offset.snapshotCompleted =>
+            notifyConsumerCheckpointComplete(offset.snapshotId + 1)
+          case Some(_) if !offset.scanSnapshot =>
+            notifyConsumerCheckpointComplete(offset.snapshotId)
+          case Some(_) =>
+            // An incomplete full snapshot cannot be recovered from a 
delta-only Consumer.
+            ()
+          case None =>
+            if (warnedLegacyConsumerSnapshots.add(offset.snapshotId)) {
+              logWarning(
+                s"Cannot advance Paimon consumer '$id' for snapshot " +
+                  s"${offset.snapshotId} because the committed Spark offset 
does not contain " +
+                  "totalSplits. This can happen when recovering a checkpoint 
written by an " +
+                  "older Paimon version. The consumer remains unchanged and 
the snapshot may " +
+                  "be replayed.")
+            }
+        }
+    }
+
+    committedOffset = Some(offset)
     logInfo(s"$committedOffset is committed.")
   }
 
@@ -194,4 +251,19 @@ class PaimonMicroBatchStream(
 
   override def table: DataTable = originTable
 
+  private def earliestReadableId(scanSnapshot: Boolean): Long = {
+    val earliestId: JLong =
+      if (!scanSnapshot && coreOptions.changelogLifecycleDecoupled()) {
+        val earliestChangelogId = 
table.changelogManager().earliestLongLivedChangelogId()
+        if (earliestChangelogId == null) {
+          table.snapshotManager().earliestSnapshotId()
+        } else {
+          earliestChangelogId
+        }
+      } else {
+        table.snapshotManager().earliestSnapshotId()
+      }
+    if (earliestId == null) 0L else earliestId.longValue()
+  }
+
 }
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 9b89729dd1..d0311a35d9 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
@@ -37,11 +37,34 @@ case class PaimonSourceOffset(snapshotId: Long, index: 
Long, scanSnapshot: Boole
   extends Offset
   with Comparable[PaimonSourceOffset] {
 
+  // Keep this out of the case class constructor so the existing 
three-argument constructor,
+  // Product3 API and pattern matching remain compatible. It is initialized 
only by the companion
+  // factory.
+  private var totalSplitsValue: Option[Long] = None
+
+  private[spark] def totalSplits: Option[Long] = totalSplitsValue
+
+  def copy(
+      snapshotId: Long = this.snapshotId,
+      index: Long = this.index,
+      scanSnapshot: Boolean = this.scanSnapshot): PaimonSourceOffset = {
+    val copied = PaimonSourceOffset(snapshotId, index, scanSnapshot)
+    if (snapshotId == this.snapshotId && scanSnapshot == this.scanSnapshot) {
+      copied.totalSplitsValue = totalSplitsValue
+    }
+    copied
+  }
+
+  private[spark] def snapshotCompleted: Boolean = {
+    totalSplits.exists(index == _ - 1)
+  }
+
   override def json(): String = {
     val node = JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.createObjectNode()
     node.put(PaimonSourceOffset.FIELD_SNAPSHOT_ID, snapshotId)
     node.put(PaimonSourceOffset.FIELD_INDEX, index)
     node.put(PaimonSourceOffset.FIELD_SCAN_SNAPSHOT, scanSnapshot)
+    totalSplits.foreach(node.put(PaimonSourceOffset.FIELD_TOTAL_SPLITS, _))
     node.toString
   }
 
@@ -65,6 +88,7 @@ object PaimonSourceOffset {
   private val FIELD_SNAPSHOT_ID = "snapshotId"
   private val FIELD_INDEX = "index"
   private val FIELD_SCAN_SNAPSHOT = "scanSnapshot"
+  private val FIELD_TOTAL_SPLITS = "totalSplits"
 
   def apply(version: Long, index: Long, scanSnapshot: Boolean): 
PaimonSourceOffset = {
     new PaimonSourceOffset(
@@ -74,15 +98,30 @@ object PaimonSourceOffset {
     )
   }
 
+  private[spark] def withTotalSplits(
+      snapshotId: Long,
+      index: Long,
+      scanSnapshot: Boolean,
+      totalSplits: Long): PaimonSourceOffset = {
+    require(totalSplits > 0, s"Total splits must be positive, but was 
$totalSplits.")
+    val offset = PaimonSourceOffset(snapshotId, index, scanSnapshot)
+    offset.totalSplitsValue = Some(totalSplits)
+    offset
+  }
+
   def apply(offset: Any): PaimonSourceOffset = {
     offset match {
       case o: PaimonSourceOffset => o
       case json: String =>
         val node = JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.readTree(json)
-        PaimonSourceOffset(
-          node.get(FIELD_SNAPSHOT_ID).asLong(),
-          node.get(FIELD_INDEX).asLong(),
-          node.get(FIELD_SCAN_SNAPSHOT).asBoolean())
+        val snapshotId = node.get(FIELD_SNAPSHOT_ID).asLong()
+        val index = node.get(FIELD_INDEX).asLong()
+        val scanSnapshot = node.get(FIELD_SCAN_SNAPSHOT).asBoolean()
+        Option(node.get(FIELD_TOTAL_SPLITS)) match {
+          case Some(totalSplits) =>
+            withTotalSplits(snapshotId, index, scanSnapshot, 
totalSplits.asLong())
+          case None => PaimonSourceOffset(snapshotId, index, scanSnapshot)
+        }
       case sc: StartingContext =>
         PaimonSourceOffset(sc.getSnapshotId, INIT_OFFSET_INDEX, 
sc.getScanFullSnapshot)
       case _ => throw new IllegalArgumentException(s"Can't parse $offset to 
PaimonSourceOffset.")
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 7e61d71ac1..68272d32f0 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
@@ -34,7 +34,20 @@ import org.apache.spark.sql.types.StructType
 import scala.collection.JavaConverters._
 import scala.collection.mutable
 
-case class IndexedDataSplit(snapshotId: Long, index: Long, entry: DataSplit)
+case class IndexedDataSplit(snapshotId: Long, index: Long, entry: DataSplit) {
+
+  // Keep the existing three-field case class API while carrying planning 
metadata internally.
+  private var totalSplitsValue: Long = -1L
+
+  private[spark] def totalSplits: Option[Long] =
+    if (totalSplitsValue < 0) None else Some(totalSplitsValue)
+
+  private[spark] def withTotalSplits(totalSplits: Long): IndexedDataSplit = {
+    require(totalSplits > 0, s"Total splits must be positive, but was 
$totalSplits.")
+    totalSplitsValue = totalSplits
+    this
+  }
+}
 
 private[spark] trait StreamHelper {
 
@@ -44,6 +57,8 @@ private[spark] trait StreamHelper {
 
   var lastTriggerMillis: Long
 
+  protected def includeSnapshotCompletionInOffset: Boolean = false
+
   private lazy val streamScan: StreamDataTableScan =
     table.newStreamScan().dropStats().asInstanceOf[StreamDataTableScan]
 
@@ -63,19 +78,28 @@ private[spark] trait StreamHelper {
   // Used to get the initial offset.
   lazy val streamScanStartingContext: StartingContext = 
streamScan.startingContext()
 
+  protected def notifyConsumerCheckpointComplete(nextSnapshot: Long): Unit =
+    streamScan.notifyCheckpointComplete(nextSnapshot)
+
   def getLatestOffset(
       startOffset: PaimonSourceOffset,
       endOffset: Option[PaimonSourceOffset],
       limit: ReadLimit): Option[PaimonSourceOffset] = {
     val indexedDataSplits = getBatch(startOffset, endOffset, Some(limit))
     indexedDataSplits.lastOption
-      .map(
+      .map {
         ids =>
-          PaimonSourceOffset(
-            ids.snapshotId,
-            ids.index,
-            scanSnapshot =
-              startOffset.scanSnapshot && 
ids.snapshotId.equals(startOffset.snapshotId)))
+          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}."))
+            PaimonSourceOffset.withTotalSplits(ids.snapshotId, ids.index, 
scanSnapshot, totalSplits)
+          } else {
+            PaimonSourceOffset(ids.snapshotId, ids.index, scanSnapshot)
+          }
+      }
   }
 
   def getBatch(
@@ -83,7 +107,11 @@ private[spark] trait StreamHelper {
       endOffset: Option[PaimonSourceOffset],
       limit: Option[ReadLimit]): Array[IndexedDataSplit] = {
     if (startOffset != null) {
-      streamScan.restore(startOffset.snapshotId, startOffset.scanSnapshot)
+      if (startOffset.snapshotCompleted) {
+        streamScan.restore(startOffset.snapshotId + 1, false)
+      } else {
+        streamScan.restore(startOffset.snapshotId, startOffset.scanSnapshot)
+      }
     }
 
     val readLimitGuard = limit.flatMap(PaimonReadLimits(_, lastTriggerMillis))
@@ -125,13 +153,19 @@ private[spark] trait StreamHelper {
     val dataSplits =
       plan.splits().asScala.collect { case dataSplit: DataSplit => dataSplit 
}.toArray
     val snapshotId = dataSplits.head.snapshotId()
+    val totalSplits = dataSplits.length.toLong
 
     dataSplits
       .sortWith((ds1, ds2) => compareByPartitionAndBucket(ds1, ds2) < 0)
       .zipWithIndex
       .map {
         case (split, idx) =>
-          IndexedDataSplit(snapshotId, idx, split)
+          val indexedSplit = IndexedDataSplit(snapshotId, idx, split)
+          if (includeSnapshotCompletionInOffset) {
+            indexedSplit.withTotalSplits(totalSplits)
+          } else {
+            indexedSplit
+          }
       }
   }
 
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
new file mode 100644
index 0000000000..0e4e901942
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/sources/PaimonSourceOffsetTest.scala
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.sources
+
+import org.apache.paimon.utils.JsonSerdeUtil
+
+import org.scalatest.funsuite.AnyFunSuite
+
+class PaimonSourceOffsetTest extends AnyFunSuite {
+
+  test("round trip total splits in offset JSON") {
+    val offset: PaimonSourceOffset = offsetWithTotalSplits(scanSnapshot = true)
+
+    val restored = PaimonSourceOffset(offset.json())
+
+    assert(restored.snapshotId == 3L)
+    assert(restored.index == 1L)
+    assert(restored.scanSnapshot)
+    assert(restored.totalSplits.contains(2L))
+  }
+
+  test("copy and Java serialization preserve total splits") {
+    val offset = offsetWithTotalSplits(scanSnapshot = true)
+
+    val copied = offset.copy(index = 0L)
+    val deserialized = org.apache.paimon.utils.InstantiationUtil.clone(offset)
+
+    assert(copied.index == 0L)
+    assert(copied.totalSplits.contains(2L))
+    assert(!copied.snapshotCompleted)
+    assert(deserialized.totalSplits.contains(2L))
+    assert(deserialized.snapshotCompleted)
+  }
+
+  test("copy clears total splits when snapshot identity changes") {
+    val offset = offsetWithTotalSplits(scanSnapshot = true)
+
+    assert(offset.copy(snapshotId = 4L).totalSplits.isEmpty)
+    assert(offset.copy(scanSnapshot = false).totalSplits.isEmpty)
+  }
+
+  test("read legacy offset JSON without total splits") {
+    val json = """{"snapshotId":3,"index":1,"scanSnapshot":false}"""
+
+    val restored = PaimonSourceOffset(json)
+
+    assert(restored.totalSplits.isEmpty)
+    
assert(!JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.readTree(restored.json()).has("totalSplits"))
+  }
+
+  test("new offset JSON remains readable by the legacy decoder") {
+    val offset = offsetWithTotalSplits(scanSnapshot = false)
+
+    val restoredByLegacyDecoder = legacyRead(offset.json())
+
+    assert(restoredByLegacyDecoder.snapshotId == 3L)
+    assert(restoredByLegacyDecoder.index == 1L)
+    assert(!restoredByLegacyDecoder.scanSnapshot)
+  }
+
+  test("total splits does not change the three-field case class API") {
+    val offset: PaimonSourceOffset = offsetWithTotalSplits(scanSnapshot = 
false)
+
+    assert(offset.productArity == 3)
+    val PaimonSourceOffset(snapshotId, index, scanSnapshot) = offset
+    assert(snapshotId == 3L)
+    assert(index == 1L)
+    assert(!scanSnapshot)
+
+    classOf[PaimonSourceOffset].getConstructor(
+      java.lang.Long.TYPE,
+      java.lang.Long.TYPE,
+      java.lang.Boolean.TYPE)
+    classOf[PaimonSourceOffset].getMethod(
+      "copy",
+      java.lang.Long.TYPE,
+      java.lang.Long.TYPE,
+      java.lang.Boolean.TYPE)
+    PaimonSourceOffset.getClass.getMethod(
+      "apply",
+      java.lang.Long.TYPE,
+      java.lang.Long.TYPE,
+      java.lang.Boolean.TYPE)
+  }
+
+  test("indexed data split keeps its three-field case class API") {
+    val split = IndexedDataSplit(3L, 1L, null)
+
+    assert(split.productArity == 3)
+    val IndexedDataSplit(snapshotId, index, entry) = split
+    assert(snapshotId == 3L)
+    assert(index == 1L)
+    assert(entry == null)
+
+    classOf[IndexedDataSplit].getConstructor(
+      java.lang.Long.TYPE,
+      java.lang.Long.TYPE,
+      classOf[org.apache.paimon.table.source.DataSplit])
+    assert(IndexedDataSplit.isInstanceOf[Function3[_, _, _, _]])
+    IndexedDataSplit.getClass.getMethod("tupled")
+    IndexedDataSplit.getClass.getMethod("curried")
+  }
+
+  private def offsetWithTotalSplits(scanSnapshot: Boolean): PaimonSourceOffset 
= {
+    PaimonSourceOffset.withTotalSplits(
+      snapshotId = 3L,
+      index = 1L,
+      scanSnapshot = scanSnapshot,
+      totalSplits = 2L)
+  }
+
+  private def legacyRead(json: String): PaimonSourceOffset = {
+    val node = JsonSerdeUtil.OBJECT_MAPPER_INSTANCE.readTree(json)
+    PaimonSourceOffset(
+      node.get("snapshotId").asLong(),
+      node.get("index").asLong(),
+      node.get("scanSnapshot").asBoolean())
+  }
+}
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
new file mode 100644
index 0000000000..c014a25474
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamITCase.scala
@@ -0,0 +1,339 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.sources
+
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.consumer.Consumer
+import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.table.FileStoreTable
+import org.apache.paimon.table.source.OutOfRangeException
+
+import org.apache.spark.sql.connector.read.streaming.ReadLimit
+
+import java.util.{Collections, HashMap}
+
+class PaimonMicroBatchStreamITCase extends PaimonSparkTestBase {
+
+  private val consumerId = "spark-consumer"
+
+  test("initialize stream when table has no snapshots") {
+    val sourceTable = createTableWithoutSnapshot()
+    assert(sourceTable.snapshotManager().earliestSnapshotId() == null)
+
+    val initial = 
createStream(sourceTable).initialOffset().asInstanceOf[PaimonSourceOffset]
+
+    assert(initial.snapshotId == 1L)
+    assert(initial.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+    assert(!initial.scanSnapshot)
+  }
+
+  test("keep legacy offset JSON when consumer is not configured") {
+    val sourceTable = createTableWithOneSnapshot()
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+
+    val latest = latestOffset(stream, initial, ReadLimit.allAvailable())
+
+    assert(latest.totalSplits.isEmpty)
+    assert(!latest.json().contains("totalSplits"))
+  }
+
+  test("create consumer only after the initial full snapshot is completely 
consumed") {
+    val sourceTable = withConsumer(createTableWithOneSnapshot())
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+
+    assert(initial.scanSnapshot)
+    val partial = latestOffset(stream, initial, ReadLimit.maxFiles(1))
+    assert(partial.index == 0L)
+    assert(partial.totalSplits.contains(2L))
+
+    stream.commit(partial)
+    assert(!sourceTable.consumerManager().consumer(consumerId).isPresent)
+
+    val complete = latestOffset(stream, partial, ReadLimit.maxFiles(1))
+    assert(complete.index == 1L)
+    assert(complete.totalSplits.contains(2L))
+
+    stream.commit(complete)
+    assert(consumerNextSnapshot(sourceTable) == complete.snapshotId + 1)
+  }
+
+  test("protect partial delta snapshot after completing the initial full 
snapshot") {
+    val sourceTable = withConsumer(createTableWithOneSnapshot())
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    assert(initial.scanSnapshot)
+
+    // Reuse the initial bucket keys so that both snapshots deterministically 
contain two splits.
+    spark.sql("INSERT INTO T VALUES (10, 'v_10_2'), (11, 'v_11_2'), (12, 
'v_12_2')")
+
+    val partialDelta = latestOffset(stream, initial, ReadLimit.maxFiles(3))
+    assert(partialDelta.snapshotId == initial.snapshotId + 1)
+    assert(partialDelta.index == 0L)
+    assert(partialDelta.totalSplits.contains(2L))
+    assert(!partialDelta.scanSnapshot)
+    assert(stream.planInputPartitions(initial, partialDelta).length == 3)
+
+    stream.commit(partialDelta)
+
+    assert(sourceTable.consumerManager().consumer(consumerId).isPresent)
+    assert(consumerNextSnapshot(sourceTable) == partialDelta.snapshotId)
+
+    val restartedInitial =
+      
createStream(sourceTable).initialOffset().asInstanceOf[PaimonSourceOffset]
+    assert(restartedInitial.snapshotId == partialDelta.snapshotId)
+    assert(restartedInitial.index == PaimonSourceOffset.INIT_OFFSET_INDEX)
+    assert(!restartedInitial.scanSnapshot)
+  }
+
+  test("restart without Spark checkpoint from consumer progress") {
+    val sourceTable = withConsumer(createTableWithOneSnapshot())
+    val firstStream = createStream(sourceTable)
+    val firstInitial = 
firstStream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val firstComplete = latestOffset(firstStream, firstInitial, 
ReadLimit.allAvailable())
+    firstStream.commit(firstComplete)
+
+    spark.sql("INSERT INTO T VALUES (20, 'v_20'), (21, 'v_21'), (22, 'v_22')")
+
+    val restartedStream = createStream(sourceTable)
+    val restartedInitial = 
restartedStream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    assert(restartedInitial.snapshotId == firstComplete.snapshotId + 1)
+    assert(!restartedInitial.scanSnapshot)
+
+    val next = latestOffset(restartedStream, restartedInitial, 
ReadLimit.allAvailable())
+    assert(next.snapshotId == restartedInitial.snapshotId)
+    assert(!next.scanSnapshot)
+  }
+
+  test("write consumer progress to the scanned branch") {
+    val mainTable = createTableWithOneSnapshot()
+    mainTable.createTag("branch-base", 
mainTable.snapshotManager().latestSnapshotId())
+    mainTable.createBranch("dev", "branch-base")
+    val branchTable = 
withConsumer(mainTable.switchToBranch("dev").asInstanceOf[FileStoreTable])
+    val stream = createStream(branchTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val complete = latestOffset(stream, initial, ReadLimit.allAvailable())
+
+    stream.commit(complete)
+
+    assert(consumerNextSnapshot(branchTable) == complete.snapshotId + 1)
+    assert(!mainTable.consumerManager().consumer(consumerId).isPresent)
+  }
+
+  test("consumer progress protects the next snapshot from expiration") {
+    val mainTable = createTableWithOneSnapshot()
+    val sourceTable = withConsumer(mainTable)
+    val stream = createStream(sourceTable)
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val complete = latestOffset(stream, initial, ReadLimit.allAvailable())
+    stream.commit(complete)
+    val protectedSnapshot = complete.snapshotId + 1
+
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+    spark.sql("INSERT INTO T VALUES (30, 'v_30')")
+    spark.sql("INSERT INTO T VALUES (40, 'v_40')")
+
+    expireSnapshotsWithMinimalRetention(mainTable)
+
+    assert(!mainTable.snapshotManager().snapshotExists(complete.snapshotId))
+    assert(mainTable.snapshotManager().snapshotExists(protectedSnapshot))
+    assert(mainTable.snapshotManager().earliestSnapshotId() == 
protectedSnapshot)
+
+    val next = latestOffset(stream, complete, ReadLimit.allAvailable())
+    assert(next.snapshotId >= protectedSnapshot)
+    assert(!next.scanSnapshot)
+    assert(stream.planInputPartitions(complete, next).nonEmpty)
+  }
+
+  test("fail rather than mix expired checkpoint with a new full scan") {
+    val mainTable = createTableWithOneSnapshot()
+    val sourceTable = withConsumer(mainTable)
+    val initialStream = createStream(sourceTable)
+    val initial = 
initialStream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val complete = latestOffset(initialStream, initial, 
ReadLimit.allAvailable())
+    assert(complete.snapshotCompleted)
+
+    // Simulate a crash before the completed offset advances consumer progress.
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+    spark.sql("INSERT INTO T VALUES (30, 'v_30')")
+    spark.sql("INSERT INTO T VALUES (40, 'v_40')")
+    val loggedDeltaEnd = latestOffset(initialStream, complete, 
ReadLimit.allAvailable())
+    assert(!loggedDeltaEnd.scanSnapshot)
+    assert(loggedDeltaEnd.snapshotId == 
mainTable.snapshotManager().latestSnapshotId())
+
+    expireSnapshotsWithMinimalRetention(mainTable)
+
+    assert(!mainTable.snapshotManager().snapshotExists(complete.snapshotId + 
1))
+    assert(!mainTable.snapshotManager().snapshotExists(complete.snapshotId + 
2))
+    assert(mainTable.snapshotManager().earliestSnapshotId() == 
loggedDeltaEnd.snapshotId)
+
+    val restartedStream = createStream(sourceTable)
+    val exception = intercept[OutOfRangeException] {
+      restartedStream.planInputPartitions(complete, loggedDeltaEnd)
+    }
+    assert(exception.getMessage.contains("no longer readable"))
+
+    val currentFullEnd = latestOffset(restartedStream, complete, 
ReadLimit.allAvailable())
+    assert(currentFullEnd.scanSnapshot)
+    assert(restartedStream.planInputPartitions(complete, 
currentFullEnd).nonEmpty)
+  }
+
+  test("fail when expired recovery start is newer than logged end") {
+    val mainTable = createTableWithOneSnapshot()
+    val sourceTable = withConsumer(mainTable)
+    val initialStream = createStream(sourceTable)
+    val initial = 
initialStream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    val complete = latestOffset(initialStream, initial, 
ReadLimit.allAvailable())
+    assert(complete.snapshotCompleted)
+
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+    spark.sql("INSERT INTO T VALUES (30, 'v_30')")
+    spark.sql("INSERT INTO T VALUES (40, 'v_40')")
+    val loggedEnd = latestOffset(initialStream, complete, 
ReadLimit.allAvailable())
+    assert(loggedEnd.snapshotId == complete.snapshotId + 3)
+
+    spark.sql("INSERT INTO T VALUES (50, 'v_50')")
+    val latestSnapshotId = mainTable.snapshotManager().latestSnapshotId()
+    assert(latestSnapshotId == loggedEnd.snapshotId + 1)
+
+    expireSnapshotsWithMinimalRetention(mainTable)
+
+    assert(!mainTable.snapshotManager().snapshotExists(loggedEnd.snapshotId))
+    assert(mainTable.snapshotManager().earliestSnapshotId() == 
latestSnapshotId)
+
+    val restartedStream = createStream(sourceTable)
+    val exception = intercept[OutOfRangeException] {
+      restartedStream.planInputPartitions(complete, loggedEnd)
+    }
+    assert(exception.getMessage.contains("newer than logged end offset"))
+  }
+
+  test("resume consumer from long-lived changelog after snapshot expiration") {
+    val mainTable = createTableWithOneSnapshot()
+    spark.sql("INSERT INTO T VALUES (20, 'v_20')")
+    spark.sql("INSERT INTO T VALUES (30, 'v_30')")
+
+    val lifecycleOptions = new HashMap[String, String]()
+    lifecycleOptions.put(CoreOptions.SNAPSHOT_NUM_RETAINED_MIN.key(), "1")
+    lifecycleOptions.put(CoreOptions.SNAPSHOT_NUM_RETAINED_MAX.key(), "1")
+    lifecycleOptions.put(CoreOptions.SNAPSHOT_TIME_RETAINED.key(), "0 ms")
+    lifecycleOptions.put(CoreOptions.CHANGELOG_NUM_RETAINED_MIN.key(), "1")
+    lifecycleOptions.put(CoreOptions.CHANGELOG_NUM_RETAINED_MAX.key(), "10")
+    lifecycleOptions.put(CoreOptions.CHANGELOG_TIME_RETAINED.key(), "1 d")
+    lifecycleOptions.put(CoreOptions.CONSUMER_CHANGELOG_ONLY.key(), "true")
+    val lifecycleTable = mainTable.copy(lifecycleOptions)
+    lifecycleTable.consumerManager().resetConsumer(consumerId, new 
Consumer(1L))
+    lifecycleTable
+      .newExpireSnapshots()
+      .config(lifecycleTable.coreOptions().expireConfig())
+      .expire()
+
+    assert(lifecycleTable.snapshotManager().earliestSnapshotId() > 1L)
+    assert(lifecycleTable.changelogManager().earliestLongLivedChangelogId() == 
1L)
+
+    val stream = createStream(withConsumer(lifecycleTable))
+    val initial = stream.initialOffset().asInstanceOf[PaimonSourceOffset]
+    assert(initial.snapshotId == 1L)
+    assert(!initial.scanSnapshot)
+
+    val end = latestOffset(stream, initial, ReadLimit.maxFiles(1))
+    assert(end.snapshotId == 1L)
+    assert(stream.planInputPartitions(initial, end).nonEmpty)
+  }
+
+  test("Spark query commits consumer progress through the source callback") {
+    withTempDir {
+      checkpointDir =>
+        val mainTable = createTableWithOneSnapshot()
+        val query = spark.readStream
+          .format("paimon")
+          .option(CoreOptions.CONSUMER_ID.key(), consumerId)
+          .option("read.stream.maxFilesPerTrigger", "1")
+          .load(mainTable.location().toString)
+          .writeStream
+          .format("memory")
+          .option("checkpointLocation", checkpointDir.getCanonicalPath)
+          .queryName("spark_consumer_memory")
+          .outputMode("append")
+          .start()
+
+        try {
+          query.processAllAvailable()
+          // Spark reports a source commit while constructing a later 
micro-batch.
+          spark.sql("INSERT INTO T VALUES (20, 'v_20'), (21, 'v_21'), (22, 
'v_22')")
+          query.processAllAvailable()
+
+          val consumer = mainTable.consumerManager().consumer(consumerId)
+          assert(consumer.isPresent)
+          assert(consumer.get().nextSnapshot() >= 2L)
+        } finally {
+          query.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 withConsumer(table: FileStoreTable): FileStoreTable = {
+    table.copy(Collections.singletonMap(CoreOptions.CONSUMER_ID.key(), 
consumerId))
+  }
+
+  private def createStream(table: FileStoreTable): PaimonMicroBatchStream = {
+    new PaimonMicroBatchStream(table, table.newReadBuilder(), "unused")
+  }
+
+  private def latestOffset(
+      stream: PaimonMicroBatchStream,
+      start: PaimonSourceOffset,
+      limit: ReadLimit): PaimonSourceOffset = {
+    stream.latestOffset(start, limit).asInstanceOf[PaimonSourceOffset]
+  }
+
+  private def expireSnapshotsWithMinimalRetention(table: FileStoreTable): Unit 
= {
+    val expireOptions = new HashMap[String, String]()
+    expireOptions.put(CoreOptions.SNAPSHOT_NUM_RETAINED_MIN.key(), "1")
+    expireOptions.put(CoreOptions.SNAPSHOT_NUM_RETAINED_MAX.key(), "1")
+    expireOptions.put(CoreOptions.SNAPSHOT_TIME_RETAINED.key(), "0 ms")
+    val expireTable = table.copy(expireOptions)
+    expireTable
+      .newExpireSnapshots()
+      .config(expireTable.coreOptions().expireConfig())
+      .expire()
+  }
+
+  private def consumerNextSnapshot(table: FileStoreTable): Long = {
+    table.consumerManager().consumer(consumerId).get().nextSnapshot()
+  }
+}
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
new file mode 100644
index 0000000000..4f605d71cc
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sources/PaimonMicroBatchStreamTest.scala
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.spark.sources
+
+import org.apache.paimon.CoreOptions
+import org.apache.paimon.table.DataTable
+import org.apache.paimon.table.source.{ReadBuilder, StreamDataTableScan}
+
+import org.mockito.ArgumentMatchers.anyLong
+import org.mockito.Mockito.{doNothing, doThrow, mock, never, times, verify, 
when}
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.io.{IOException, UncheckedIOException}
+import java.util.Collections
+
+class PaimonMicroBatchStreamTest extends AnyFunSuite {
+
+  test("never advance consumer past an incomplete snapshot") {
+    val (stream, scan) = createStreamWithConsumer()
+    val partial = consumerOffset(index = 0L, totalSplits = 2L)
+    val complete = consumerOffset(index = 1L, totalSplits = 2L)
+
+    stream.commit(partial)
+    verify(scan).notifyCheckpointComplete(5L)
+    verify(scan, never()).notifyCheckpointComplete(6L)
+
+    stream.commit(complete)
+    verify(scan).notifyCheckpointComplete(6L)
+  }
+
+  test("do not advance consumer from a legacy offset without total splits") {
+    val (stream, scan) = createStreamWithConsumer()
+    val legacyOffset = 
PaimonSourceOffset("""{"snapshotId":5,"index":1,"scanSnapshot":false}""")
+
+    stream.commit(legacyOffset)
+
+    verify(scan, never()).notifyCheckpointComplete(anyLong())
+  }
+
+  test("propagate consumer update failure and allow retry") {
+    val (stream, scan) = createStreamWithConsumer()
+    val complete = consumerOffset(index = 1L, totalSplits = 2L)
+    val failure = new UncheckedIOException(new IOException("expected failure"))
+    doThrow(failure).doNothing().when(scan).notifyCheckpointComplete(6L)
+
+    val thrown = intercept[UncheckedIOException] {
+      stream.commit(complete)
+    }
+    assert(thrown eq failure)
+
+    stream.commit(complete)
+    verify(scan, times(2)).notifyCheckpointComplete(6L)
+  }
+
+  private def consumerOffset(index: Long, totalSplits: Long): 
PaimonSourceOffset = {
+    PaimonSourceOffset.withTotalSplits(
+      snapshotId = 5L,
+      index = index,
+      scanSnapshot = false,
+      totalSplits = totalSplits)
+  }
+
+  private def createStreamWithConsumer(): (PaimonMicroBatchStream, 
StreamDataTableScan) = {
+    val table = mock(classOf[DataTable])
+    val scan = mock(classOf[StreamDataTableScan])
+    val readBuilder = mock(classOf[ReadBuilder])
+    when(table.options())
+      .thenReturn(Collections.singletonMap(CoreOptions.CONSUMER_ID.key(), 
"spark-consumer"))
+    when(table.newStreamScan()).thenReturn(scan)
+    when(scan.dropStats()).thenReturn(scan)
+    (new PaimonMicroBatchStream(table, readBuilder, "unused"), scan)
+  }
+}

Reply via email to