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

mxm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 3869163581 Spark: Don't reapply stream-from-timestamp when a stream 
resumes (#18348)
3869163581 is described below

commit 38691635818adb63cf6e425fe8299af63593ada8
Author: Maximilian Michels <[email protected]>
AuthorDate: Mon Oct 12 11:06:32 2026 +0200

    Spark: Don't reapply stream-from-timestamp when a stream resumes (#18348)
---
 docs/docs/spark-configuration.md                   |   2 +-
 .../spark/source/AsyncSparkMicroBatchPlanner.java  |   4 +-
 .../spark/source/BaseSparkMicroBatchPlanner.java   |  12 +
 .../spark/source/SparkMicroBatchPlanner.java       |   9 +
 .../spark/source/SparkMicroBatchStream.java        |  19 +-
 .../spark/source/SyncSparkMicroBatchPlanner.java   |   8 +-
 .../spark/source/TestStructuredStreamingRead3.java | 249 +++++++++++++++++++++
 7 files changed, 294 insertions(+), 9 deletions(-)

diff --git a/docs/docs/spark-configuration.md b/docs/docs/spark-configuration.md
index 857f7c40f3..f45f89125a 100644
--- a/docs/docs/spark-configuration.md
+++ b/docs/docs/spark-configuration.md
@@ -233,7 +233,7 @@ spark.read
 | file-open-cost  | As per table property | Overrides this table's 
read.split.open-file-cost                                                       
                                                                       |
 | vectorization-enabled  | As per table property | Overrides this table's 
read.parquet.vectorization.enabled                                              
                                                                       |
 | batch-size  | As per table property | Overrides this table's 
read.parquet.vectorization.batch-size                                           
                                                                       |
-| stream-from-timestamp | (none) | A timestamp in milliseconds to stream from; 
if before the oldest known ancestor snapshot, the oldest will be used           
                                                  |
+| stream-from-timestamp | (none) | A timestamp in milliseconds to stream from; 
if before the oldest known ancestor snapshot, the oldest will be used. Only 
applies until the query has a position. A query restarted from a checkpoint 
continues from its saved position |
 | streaming-max-files-per-micro-batch | INT_MAX | Maximum number of files per 
microbatch                                                                      
                                                                  |
 | streaming-max-rows-per-micro-batch  | INT_MAX | "Soft maximum" number of 
rows per microbatch; always includes all rows in next unprocessed file, 
excludes additional files if their inclusion would exceed the soft max limit |
 | async-micro-batch-planning-enabled      | false                     | 
Enables asynchronous microbatch planning to reduce planning latency by 
pre-fetching file scan tasks                                                    
                       |
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java
index fcbee2a87e..851421917a 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/AsyncSparkMicroBatchPlanner.java
@@ -252,7 +252,9 @@ class AsyncSparkMicroBatchPlanner extends 
BaseSparkMicroBatchPlanner implements
       return StreamingOffset.START_OFFSET;
     }
 
-    if (table().currentSnapshot().timestampMillis() < 
readConf().streamFromTimestamp()) {
+    // Only a new stream starts from the timestamp. A resumed stream continues 
from its offset.
+    if (startOffset.equals(StreamingOffset.START_OFFSET)
+        && table().currentSnapshot().timestampMillis() < 
readConf().streamFromTimestamp()) {
       LOG.info("latestOffset returning START_OFFSET, currentSnapshot() < 
fromTimestamp");
       return StreamingOffset.START_OFFSET;
     }
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java
index 9298c2bbdf..b80d04ee13 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/BaseSparkMicroBatchPlanner.java
@@ -117,6 +117,18 @@ abstract class BaseSparkMicroBatchPlanner implements 
SparkMicroBatchPlanner {
     return nextSnapshot;
   }
 
+  @Override
+  public StreamingOffset latestValidSnapshotOffset() {
+    for (Snapshot snapshot : SnapshotUtil.currentAncestors(table)) {
+      if (shouldProcess(snapshot)) {
+        return new StreamingOffset(
+            snapshot.snapshotId(), MicroBatchUtils.addedFilesCount(table, 
snapshot), false);
+      }
+    }
+
+    return null;
+  }
+
   static class UnpackedLimits {
     private long maxRows = Integer.MAX_VALUE;
     private long maxFiles = Integer.MAX_VALUE;
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java
index 1986ddac5d..014de230b0 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchPlanner.java
@@ -42,6 +42,15 @@ interface SparkMicroBatchPlanner {
    */
   StreamingOffset latestOffset(StreamingOffset startOffset, ReadLimit limit);
 
+  /**
+   * Return the offset at the end of the latest snapshot in the table's 
current history that the
+   * stream reads.
+   *
+   * @return the offset, or {@code null} if the stream reads no snapshot in 
the current history
+   * @throws IllegalStateException if a later snapshot can't be skipped
+   */
+  StreamingOffset latestValidSnapshotOffset();
+
   /** Stop the planner and release any resources. */
   void stop();
 }
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java
index 2d510e50ad..0370c43e6b 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SparkMicroBatchStream.java
@@ -66,6 +66,7 @@ public class SparkMicroBatchStream implements 
MicroBatchStream, SupportsTriggerA
   private final boolean cacheDeleteFilesOnExecutors;
   private SparkMicroBatchPlanner planner;
   private StreamingOffset lastOffsetForTriggerAvailableNow;
+  private boolean noSnapshotMatchedForTriggerAvailableNow;
 
   SparkMicroBatchStream(
       JavaSparkContext sparkContext,
@@ -215,6 +216,12 @@ public class SparkMicroBatchStream implements 
MicroBatchStream, SupportsTriggerA
         "Invalid start offset: %s is not a StreamingOffset",
         startOffset);
 
+    // Snapshots committed after Trigger.AvailableNow started are left for the 
next run
+    if (noSnapshotMatchedForTriggerAvailableNow
+        && StreamingOffset.START_OFFSET.equals(startOffset)) {
+      return StreamingOffset.START_OFFSET;
+    }
+
     // Initialize planner if not already done
     if (planner == null) {
       initializePlanner((StreamingOffset) startOffset, null);
@@ -244,10 +251,16 @@ public class SparkMicroBatchStream implements 
MicroBatchStream, SupportsTriggerA
   public void prepareForTriggerAvailableNow() {
     LOG.info("The streaming query reports to use Trigger.AvailableNow");
 
-    lastOffsetForTriggerAvailableNow =
+    StreamingOffset lastOffset =
         (StreamingOffset) latestOffset(initialOffset, 
ReadLimit.allAvailable());
-
-    LOG.info("lastOffset for Trigger.AvailableNow is {}", 
lastOffsetForTriggerAvailableNow.json());
+    // START_OFFSET means that no snapshot matched stream-from-timestamp. A 
new stream has nothing
+    // to read in this run. A resumed stream continues from its offset and is 
capped at the latest
+    // snapshot the planner reads, since a cap at a skipped snapshot is never 
reached.
+    this.noSnapshotMatchedForTriggerAvailableNow = 
StreamingOffset.START_OFFSET.equals(lastOffset);
+    this.lastOffsetForTriggerAvailableNow =
+        noSnapshotMatchedForTriggerAvailableNow ? 
planner.latestValidSnapshotOffset() : lastOffset;
+
+    LOG.info("lastOffset for Trigger.AvailableNow is {}", 
lastOffsetForTriggerAvailableNow);
 
     // Reset planner so it gets recreated with the cap on next call
     if (planner != null) {
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java
index f1b0029c54..b80e2e94a4 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/source/SyncSparkMicroBatchPlanner.java
@@ -118,15 +118,15 @@ class SyncSparkMicroBatchPlanner extends 
BaseSparkMicroBatchPlanner {
       return StreamingOffset.START_OFFSET;
     }
 
-    if (table().currentSnapshot().timestampMillis() < fromTimestamp) {
-      return StreamingOffset.START_OFFSET;
-    }
-
     // end offset can expand to multiple snapshots
     StreamingOffset startingOffset = startOffset;
 
+    // Only a new stream starts from the timestamp. A resumed stream continues 
from its offset.
     if (startOffset.equals(StreamingOffset.START_OFFSET)) {
       startingOffset = MicroBatchUtils.determineStartingOffset(table(), 
fromTimestamp);
+      if (startingOffset.equals(StreamingOffset.START_OFFSET)) {
+        return StreamingOffset.START_OFFSET;
+      }
     }
 
     Snapshot curSnapshot = table().snapshot(startingOffset.snapshotId());
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java
index 8003497bd1..cb6ea12326 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestStructuredStreamingRead3.java
@@ -112,6 +112,9 @@ public final class TestStructuredStreamingRead3 extends 
CatalogTestBase {
 
   private final AtomicInteger microBatches = new AtomicInteger();
 
+  private final List<SimpleRecord> recordsReadByStream =
+      Collections.synchronizedList(Lists.newArrayList());
+
   @Parameter(index = 3)
   private Boolean async;
 
@@ -572,6 +575,163 @@ public final class TestStructuredStreamingRead3 extends 
CatalogTestBase {
     }
   }
 
+  @TestTemplate
+  void latestOffsetIgnoresStreamFromTimestampWhenResuming() {
+    appendData(List.of(new SimpleRecord(1, "one")));
+    table.refresh();
+    Snapshot resumedSnapshot = table.currentSnapshot();
+    appendData(List.of(new SimpleRecord(2, "two")));
+    table.refresh();
+    long latestSnapshotId = table.currentSnapshot().snapshotId();
+
+    SparkMicroBatchStream stream =
+        newMicroBatchStream(
+            ImmutableMap.of(
+                SparkReadOptions.STREAM_FROM_TIMESTAMP,
+                Long.toString(timestampAfterCurrentSnapshot())),
+            "resumed-stream-checkpoint");
+
+    try {
+      StreamingOffset resumedOffset =
+          new StreamingOffset(
+              resumedSnapshot.snapshotId(),
+              MicroBatchUtils.addedFilesCount(table, resumedSnapshot),
+              false);
+      StreamingOffset endOffset =
+          (StreamingOffset) stream.latestOffset(resumedOffset, 
stream.getDefaultReadLimit());
+
+      assertThat(endOffset.snapshotId()).isEqualTo(latestSnapshotId);
+    } finally {
+      stream.stop();
+    }
+  }
+
+  @TestTemplate
+  void availableNowResumeStopsAtSnapshotsAvailableAtStart() {
+    appendData(List.of(new SimpleRecord(1, "one")));
+    table.refresh();
+    Snapshot committed = table.currentSnapshot();
+    appendData(List.of(new SimpleRecord(2, "two")));
+    table.refresh();
+    Snapshot latest = table.currentSnapshot();
+    // A timestamp after the newest snapshot stores START_OFFSET as the 
initial offset
+    SparkMicroBatchStream stream =
+        newMicroBatchStream(
+            ImmutableMap.of(
+                SparkReadOptions.STREAM_FROM_TIMESTAMP,
+                Long.toString(timestampAfterCurrentSnapshot())),
+            "available-now-resume-checkpoint");
+
+    try {
+      stream.prepareForTriggerAvailableNow();
+      appendData(List.of(new SimpleRecord(3, "three")));
+      StreamingOffset committedOffset =
+          new StreamingOffset(
+              committed.snapshotId(), MicroBatchUtils.addedFilesCount(table, 
committed), false);
+
+      assertThat(stream.latestOffset(committedOffset, 
stream.getDefaultReadLimit()))
+          .isEqualTo(
+              new StreamingOffset(
+                  latest.snapshotId(), MicroBatchUtils.addedFilesCount(table, 
latest), false));
+    } finally {
+      stream.stop();
+    }
+  }
+
+  @TestTemplate
+  void availableNowResumeStopsAtLatestAppendSnapshot() {
+    appendData(List.of(new SimpleRecord(1, "one")));
+    table.refresh();
+    Snapshot committed = table.currentSnapshot();
+    appendData(List.of(new SimpleRecord(2, "two")));
+    table.refresh();
+    Snapshot latestAppend = table.currentSnapshot();
+    table.rewriteManifests().clusterBy(file -> 1).commit();
+    table.refresh();
+    
assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.REPLACE);
+    SparkMicroBatchStream stream =
+        newMicroBatchStream(
+            ImmutableMap.of(
+                SparkReadOptions.STREAM_FROM_TIMESTAMP,
+                Long.toString(timestampAfterCurrentSnapshot())),
+            "available-now-resume-replace-checkpoint");
+
+    try {
+      stream.prepareForTriggerAvailableNow();
+      appendData(List.of(new SimpleRecord(3, "three")));
+      StreamingOffset committedOffset =
+          new StreamingOffset(
+              committed.snapshotId(), MicroBatchUtils.addedFilesCount(table, 
committed), false);
+
+      assertThat(stream.latestOffset(committedOffset, 
stream.getDefaultReadLimit()))
+          .isEqualTo(
+              new StreamingOffset(
+                  latestAppend.snapshotId(),
+                  MicroBatchUtils.addedFilesCount(table, latestAppend),
+                  false));
+    } finally {
+      stream.stop();
+    }
+  }
+
+  @TestTemplate
+  void availableNowIgnoresSnapshotsCommittedAfterStart() {
+    appendData(List.of(new SimpleRecord(1, "one")));
+    SparkMicroBatchStream stream =
+        newMicroBatchStream(
+            ImmutableMap.of(
+                SparkReadOptions.STREAM_FROM_TIMESTAMP,
+                Long.toString(timestampAfterCurrentSnapshot())),
+            "available-now-new-stream-checkpoint");
+
+    try {
+      stream.prepareForTriggerAvailableNow();
+      appendData(List.of(new SimpleRecord(2, "two")));
+
+      assertThat(stream.latestOffset(stream.initialOffset(), 
stream.getDefaultReadLimit()))
+          .isEqualTo(StreamingOffset.START_OFFSET);
+    } finally {
+      stream.stop();
+    }
+  }
+
+  @TestTemplate
+  void availableNowReadsNothingFromEmptyTable() {
+    SparkMicroBatchStream stream =
+        newMicroBatchStream(ImmutableMap.of(), 
"available-now-empty-table-checkpoint");
+
+    try {
+      stream.prepareForTriggerAvailableNow();
+
+      assertThat(stream.latestOffset(stream.initialOffset(), 
stream.getDefaultReadLimit()))
+          .isEqualTo(StreamingOffset.START_OFFSET);
+    } finally {
+      stream.stop();
+    }
+  }
+
+  @TestTemplate
+  void availableNowCapRejectsDeleteSnapshotWithoutSkipOption() {
+    table.updateSpec().removeField("id_bucket").addField(ref("id")).commit();
+    appendData(List.of(new SimpleRecord(1, "one")));
+    table.newDelete().deleteFromRowFilter(Expressions.equal("id", 1)).commit();
+    
assertThat(table.currentSnapshot().operation()).isEqualTo(DataOperations.DELETE);
+    SparkMicroBatchStream stream =
+        newMicroBatchStream(
+            ImmutableMap.of(
+                SparkReadOptions.STREAM_FROM_TIMESTAMP,
+                Long.toString(timestampAfterCurrentSnapshot())),
+            "available-now-delete-checkpoint");
+
+    try {
+      assertThatThrownBy(stream::prepareForTriggerAvailableNow)
+          .isInstanceOf(IllegalStateException.class)
+          .hasMessageStartingWith("Cannot process delete snapshot");
+    } finally {
+      stream.stop();
+    }
+  }
+
   @TestTemplate
   public void testReadStreamOnIcebergThenAddData() throws Exception {
     List<List<SimpleRecord>> expected = TEST_DATA_MULTIPLE_SNAPSHOTS;
@@ -721,6 +881,48 @@ public final class TestStructuredStreamingRead3 extends 
CatalogTestBase {
     assertThat(actual).containsExactlyInAnyOrderElementsOf(expectedRecordList);
   }
 
+  @TestTemplate
+  void resumeIgnoresLaterStreamFromTimestamp() throws Exception {
+    File checkpoint = temp.resolve("checkpoint").toFile();
+    appendData(List.of(new SimpleRecord(1, "one")));
+    StreamingQuery query = startStreamFromTimestamp(checkpoint, 
timestampAfterCurrentSnapshot());
+    query.processAllAvailable();
+    List<SimpleRecord> expected = Lists.newArrayList(new SimpleRecord(2, 
"two"));
+    appendData(expected);
+    awaitRecordsReadByStream(query, expected);
+    query.stop();
+
+    List<SimpleRecord> appendedWhileStopped = List.of(new SimpleRecord(3, 
"three"));
+    appendData(appendedWhileStopped);
+    expected.addAll(appendedWhileStopped);
+
+    StreamingQuery restarted =
+        startStreamFromTimestamp(checkpoint, timestampAfterCurrentSnapshot());
+    awaitRecordsReadByStream(restarted, expected);
+  }
+
+  @TestTemplate
+  void availableNowResumeIgnoresLaterStreamFromTimestamp() throws Exception {
+    File checkpoint = temp.resolve("checkpoint").toFile();
+    appendData(List.of(new SimpleRecord(1, "one")));
+    StreamingQuery query = startStreamFromTimestamp(checkpoint, 
timestampAfterCurrentSnapshot());
+    query.processAllAvailable();
+    List<SimpleRecord> expected = Lists.newArrayList(new SimpleRecord(2, 
"two"));
+    appendData(expected);
+    awaitRecordsReadByStream(query, expected);
+    query.stop();
+
+    List<SimpleRecord> appendedWhileStopped = List.of(new SimpleRecord(3, 
"three"));
+    appendData(appendedWhileStopped);
+    expected.addAll(appendedWhileStopped);
+
+    StreamingQuery rerun =
+        startStreamFromTimestamp(
+            checkpoint, timestampAfterCurrentSnapshot(), 
Trigger.AvailableNow());
+    
assertThat(rerun.awaitTermination(Duration.ofMinutes(1).toMillis())).isTrue();
+    
assertThat(recordsReadByStream).containsExactlyInAnyOrderElementsOf(expected);
+  }
+
   @TestTemplate
   public void testResumingStreamReadFromCheckpoint() throws Exception {
     File writerCheckpointFolder = 
temp.resolve("writer-checkpoint-folder").toFile();
@@ -1163,6 +1365,53 @@ public final class TestStructuredStreamingRead3 extends 
CatalogTestBase {
         .collectAsList();
   }
 
+  private StreamingQuery startStreamFromTimestamp(File checkpoint, long 
fromTimestamp)
+      throws TimeoutException {
+    return startStreamFromTimestamp(checkpoint, fromTimestamp, 
Trigger.ProcessingTime(0L));
+  }
+
+  private StreamingQuery startStreamFromTimestamp(
+      File checkpoint, long fromTimestamp, Trigger trigger) throws 
TimeoutException {
+    Map<String, String> options = Maps.newHashMap();
+    options.put(SparkReadOptions.STREAM_FROM_TIMESTAMP, 
Long.toString(fromTimestamp));
+    options.put(SparkReadOptions.ASYNC_MICRO_BATCH_PLANNING_ENABLED, 
async.toString());
+    if (async) {
+      options.put(SparkReadOptions.STREAMING_SNAPSHOT_POLLING_INTERVAL_MS, 
"1");
+    }
+
+    return spark
+        .readStream()
+        .options(options)
+        .format("iceberg")
+        .load(tableName)
+        .writeStream()
+        .option("checkpointLocation", checkpoint.toString())
+        .trigger(trigger)
+        .foreachBatch(
+            (VoidFunction2<Dataset<Row>, Long>)
+                (batch, batchId) -> {
+                  List<SimpleRecord> records =
+                      
batch.as(Encoders.bean(SimpleRecord.class)).collectAsList();
+                  recordsReadByStream.addAll(records);
+                })
+        .start();
+  }
+
+  private void awaitRecordsReadByStream(StreamingQuery query, 
List<SimpleRecord> expected) {
+    Awaitility.await()
+        .atMost(Duration.ofSeconds(30))
+        .untilAsserted(
+            () -> {
+              query.processAllAvailable();
+              
assertThat(recordsReadByStream).containsExactlyInAnyOrderElementsOf(expected);
+            });
+  }
+
+  private long timestampAfterCurrentSnapshot() {
+    table.refresh();
+    return waitUntilAfter(table.currentSnapshot().timestampMillis());
+  }
+
   private SparkMicroBatchStream newMicroBatchStream(
       Map<String, String> options, String checkpointDirName) {
     Map<String, String> allOptions = Maps.newHashMap(options);

Reply via email to