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 2463bd103b [flink] Honor snapshot limit in exactly-once monitor source 
(#9677)
2463bd103b is described below

commit 2463bd103be56295bc53f68fbd3e1f92bb80dbe4
Author: jianguotian <[email protected]>
AuthorDate: Tue Sep 8 16:40:17 2026 +0800

    [flink] Honor snapshot limit in exactly-once monitor source (#9677)
---
 .../flink/source/operator/MonitorSource.java       |  68 +++++++++++-
 .../flink/source/operator/OperatorSourceTest.java  | 122 +++++++++++++++++++++
 2 files changed, 188 insertions(+), 2 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/operator/MonitorSource.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/operator/MonitorSource.java
index 3fbec3697b..740cb59311 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/operator/MonitorSource.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/operator/MonitorSource.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.flink.source.operator;
 
+import org.apache.paimon.flink.FlinkConnectorOptions;
 import org.apache.paimon.flink.NestedProjectedRowData;
 import org.apache.paimon.flink.source.AbstractNonCoordinatedSource;
 import org.apache.paimon.flink.source.AbstractNonCoordinatedSourceReader;
@@ -26,6 +27,7 @@ import org.apache.paimon.flink.source.PaimonDataStreamSource;
 import org.apache.paimon.flink.source.SimpleSourceSplit;
 import org.apache.paimon.flink.source.SplitListState;
 import org.apache.paimon.flink.utils.JavaTypeInfo;
+import org.apache.paimon.options.Options;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.sink.ChannelComputer;
 import org.apache.paimon.table.source.DataSplit;
@@ -56,7 +58,9 @@ import org.slf4j.LoggerFactory;
 
 import javax.annotation.Nullable;
 
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Deque;
 import java.util.List;
 import java.util.NavigableMap;
 import java.util.OptionalLong;
@@ -96,16 +100,27 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
     private final long monitorInterval;
     private final boolean emitSnapshotWatermark;
     private final boolean isBounded;
+    private final int maxSnapshotCount;
 
     public MonitorSource(
             ReadBuilder readBuilder,
             long monitorInterval,
             boolean emitSnapshotWatermark,
             boolean isBounded) {
+        this(readBuilder, monitorInterval, emitSnapshotWatermark, isBounded, 
-1);
+    }
+
+    MonitorSource(
+            ReadBuilder readBuilder,
+            long monitorInterval,
+            boolean emitSnapshotWatermark,
+            boolean isBounded,
+            int maxSnapshotCount) {
         this.readBuilder = readBuilder;
         this.monitorInterval = monitorInterval;
         this.emitSnapshotWatermark = emitSnapshotWatermark;
         this.isBounded = isBounded;
+        this.maxSnapshotCount = maxSnapshotCount;
     }
 
     @Override
@@ -136,6 +151,7 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
                                         Long.parseLong(x.split(":")[0]),
                                         Long.parseLong(x.split(":")[1])));
         private final TreeMap<Long, Long> nextSnapshotPerCheckpoint = new 
TreeMap<>();
+        private final Deque<Long> inFlightNextSnapshots = new ArrayDeque<>();
         private CompletableFuture<Void> availableFuture = 
CompletableFuture.completedFuture(null);
 
         @Override
@@ -143,8 +159,19 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
             NavigableMap<Long, Long> nextSnapshots =
                     nextSnapshotPerCheckpoint.headMap(checkpointId, true);
             OptionalLong max = 
nextSnapshots.values().stream().mapToLong(Long::longValue).max();
-            max.ifPresent(scan::notifyCheckpointComplete);
+            boolean limitReached = snapshotLimitReached();
+            max.ifPresent(
+                    completedNextSnapshot -> {
+                        scan.notifyCheckpointComplete(completedNextSnapshot);
+                        while (!inFlightNextSnapshots.isEmpty()
+                                && inFlightNextSnapshots.getFirst() <= 
completedNextSnapshot) {
+                            inFlightNextSnapshots.removeFirst();
+                        }
+                    });
             nextSnapshots.clear();
+            if (limitReached && !snapshotLimitReached()) {
+                availableFuture.complete(null);
+            }
         }
 
         @Override
@@ -190,6 +217,8 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
             for (Tuple2<Long, Long> tuple2 : nextSnapshotState.get()) {
                 nextSnapshotPerCheckpoint.put(tuple2.f0, tuple2.f1);
             }
+            inFlightNextSnapshots.clear();
+            availableFuture.complete(null);
         }
 
         @Override
@@ -199,11 +228,20 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
 
         @Override
         public InputStatus pollNext(ReaderOutput<Split> readerOutput) throws 
Exception {
+            if (snapshotLimitReached()) {
+                return InputStatus.NOTHING_AVAILABLE;
+            }
+
             boolean isEmpty;
             try {
                 List<Split> splits = isBounded ? batchScan.plan().splits() : 
scan.plan().splits();
                 isEmpty = splits.isEmpty();
                 splits.forEach(readerOutput::collect);
+                if (!isBounded && maxSnapshotCount > 0 && !isEmpty) {
+                    inFlightNextSnapshots.addLast(
+                            Preconditions.checkNotNull(
+                                    scan.checkpoint(), "Non-empty streaming 
plan without state."));
+                }
 
                 if (emitSnapshotWatermark && !isBounded) {
                     Long watermark = scan.watermark();
@@ -231,8 +269,18 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
                                 });
                 return InputStatus.NOTHING_AVAILABLE;
             }
+            if (snapshotLimitReached()) {
+                availableFuture = new CompletableFuture<>();
+                return InputStatus.NOTHING_AVAILABLE;
+            }
             return InputStatus.MORE_AVAILABLE;
         }
+
+        private boolean snapshotLimitReached() {
+            return !isBounded
+                    && maxSnapshotCount > 0
+                    && inFlightNextSnapshots.size() >= maxSnapshotCount;
+        }
     }
 
     public static DataStream<RowData> buildSource(
@@ -307,8 +355,24 @@ public class MonitorSource extends 
AbstractNonCoordinatedSource<Split> {
             @Nullable Table table,
             RowType readType,
             boolean blobAsDescriptor) {
+        int maxSnapshotCount =
+                table == null
+                        ? -1
+                        : Options.fromMap(table.options())
+                                
.get(FlinkConnectorOptions.SCAN_MAX_SNAPSHOT_COUNT);
+        Preconditions.checkArgument(
+                isBounded
+                        || maxSnapshotCount <= 0
+                        || env.getCheckpointConfig().isCheckpointingEnabled(),
+                "Option '%s' is only supported for streaming monitor source 
when checkpointing is enabled.",
+                FlinkConnectorOptions.SCAN_MAX_SNAPSHOT_COUNT.key());
         MonitorSource monitorSource =
-                new MonitorSource(readBuilder, monitorInterval, 
emitSnapshotWatermark, isBounded);
+                new MonitorSource(
+                        readBuilder,
+                        monitorInterval,
+                        emitSnapshotWatermark,
+                        isBounded,
+                        maxSnapshotCount);
         Source<Split, SimpleSourceSplit, NoOpEnumState> source = monitorSource;
         if (table != null) {
             source = new PaimonDataStreamSource<>(monitorSource, table);
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/operator/OperatorSourceTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/operator/OperatorSourceTest.java
index 9c57f27b86..4446278be1 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/operator/OperatorSourceTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/operator/OperatorSourceTest.java
@@ -23,6 +23,8 @@ import org.apache.paimon.catalog.CatalogContext;
 import org.apache.paimon.catalog.CatalogFactory;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.flink.source.FlinkSourceBuilder;
+import org.apache.paimon.flink.source.SimpleSourceSplit;
 import org.apache.paimon.flink.utils.TestingMetricUtils;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.table.Table;
@@ -34,11 +36,16 @@ import org.apache.paimon.table.source.TableRead;
 import org.apache.paimon.types.DataTypes;
 
 import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.connector.testutils.source.reader.TestingReaderOutput;
+import org.apache.flink.core.io.InputStatus;
 import org.apache.flink.metrics.MetricGroup;
 import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
 import org.apache.flink.runtime.event.WatermarkEvent;
+import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 import org.apache.flink.streaming.api.operators.SourceOperator;
+import org.apache.flink.streaming.api.transformations.SourceTransformation;
 import org.apache.flink.streaming.api.watermark.Watermark;
 import org.apache.flink.streaming.runtime.io.PushingAsyncDataInput;
 import org.apache.flink.streaming.runtime.streamrecord.LatencyMarker;
@@ -68,8 +75,11 @@ import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
+import static org.apache.paimon.CoreOptions.CONSUMER_EXPIRATION_TIME;
 import static org.apache.paimon.CoreOptions.CONSUMER_ID;
+import static 
org.apache.paimon.flink.FlinkConnectorOptions.SCAN_MAX_SNAPSHOT_COUNT;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Test for {@link MonitorSource} and {@link ReadOperator}. */
 public class OperatorSourceTest {
@@ -92,6 +102,8 @@ public class OperatorSourceTest {
                         .column("c", DataTypes.INT())
                         .primaryKey("a")
                         .option(CONSUMER_ID.key(), "my_consumer")
+                        .option(CONSUMER_EXPIRATION_TIME.key(), "1 d")
+                        .option(SCAN_MAX_SNAPSHOT_COUNT.key(), "1")
                         .option("bucket", "1")
                         .build();
         Identifier identifier = Identifier.create("default", "t");
@@ -195,6 +207,116 @@ public class OperatorSourceTest {
         }
     }
 
+    @Test
+    public void testMonitorSourceSnapshotLimitRequiresCheckpointing() {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+
+        assertThatThrownBy(
+                        () -> new 
FlinkSourceBuilder(table).env(env).sourceBounded(false).build())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(SCAN_MAX_SNAPSHOT_COUNT.key())
+                .hasMessageContaining("checkpoint");
+    }
+
+    @Test
+    public void testMonitorSourceLimitsSnapshotsUntilCheckpointCompletes() 
throws Exception {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.enableCheckpointing(10);
+        DataStream<RowData> dataStream =
+                new 
FlinkSourceBuilder(table).env(env).sourceBounded(false).build();
+        SourceTransformation<?, ?, ?> sourceTransformation =
+                
dataStream.getTransformation().getTransitivePredecessors().stream()
+                        .filter(SourceTransformation.class::isInstance)
+                        .map(SourceTransformation.class::cast)
+                        .findFirst()
+                        .orElseThrow(AssertionError::new);
+        @SuppressWarnings("unchecked")
+        SourceReader<Split, SimpleSourceSplit> reader =
+                (SourceReader<Split, SimpleSourceSplit>)
+                        sourceTransformation.getSource().createReader(null);
+        TestingReaderOutput<Split> output = new TestingReaderOutput<>();
+
+        writeToTable(1, 1, 1);
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        assertThat(output.getEmittedRecords()).hasSize(1);
+
+        writeToTable(2, 2, 2);
+        assertThat(reader.isAvailable()).isNotDone();
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        assertThat(output.getEmittedRecords()).hasSize(1);
+
+        reader.snapshotState(1L);
+        reader.notifyCheckpointComplete(1L);
+
+        assertThat(reader.isAvailable()).isDone();
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        assertThat(output.getEmittedRecords()).hasSize(2);
+    }
+
+    @Test
+    public void testCheckpointBeforeSnapshotDoesNotReleaseSnapshotLimit() 
throws Exception {
+        MonitorSource source = new MonitorSource(table.newReadBuilder(), 10, 
false, false, 1);
+        SourceReader<Split, SimpleSourceSplit> reader = 
source.createReader(null);
+        TestingReaderOutput<Split> output = new TestingReaderOutput<>();
+
+        reader.snapshotState(1L);
+        writeToTable(1, 1, 1);
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+
+        reader.notifyCheckpointComplete(1L);
+        assertThat(reader.isAvailable()).isNotDone();
+
+        reader.snapshotState(2L);
+        reader.notifyCheckpointComplete(2L);
+        assertThat(reader.isAvailable()).isDone();
+    }
+
+    @Test
+    public void testCompletedCheckpointReleasesCoveredSnapshotCredits() throws 
Exception {
+        MonitorSource source = new MonitorSource(table.newReadBuilder(), 10, 
false, false, 2);
+        SourceReader<Split, SimpleSourceSplit> reader = 
source.createReader(null);
+        TestingReaderOutput<Split> output = new TestingReaderOutput<>();
+
+        writeToTable(1, 1, 1);
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.MORE_AVAILABLE);
+        reader.snapshotState(1L);
+
+        writeToTable(2, 2, 2);
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        assertThat(output.getEmittedRecords()).hasSize(2);
+
+        reader.notifyCheckpointComplete(1L);
+        assertThat(reader.isAvailable()).isDone();
+
+        writeToTable(3, 3, 3);
+        
assertThat(reader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        assertThat(output.getEmittedRecords()).hasSize(3);
+    }
+
+    @Test
+    public void testMonitorSourceSnapshotLimitIsOpenAfterRestore() throws 
Exception {
+        MonitorSource source = new MonitorSource(table.newReadBuilder(), 10, 
false, false, 1);
+        SourceReader<Split, SimpleSourceSplit> reader = 
source.createReader(null);
+
+        writeToTable(1, 1, 1);
+        assertThat(reader.pollNext(new TestingReaderOutput<>()))
+                .isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        List<SimpleSourceSplit> checkpoint = reader.snapshotState(1L);
+
+        MonitorSource restoredSource =
+                new MonitorSource(table.newReadBuilder(), 10, false, false, 1);
+        SourceReader<Split, SimpleSourceSplit> restoredReader = 
restoredSource.createReader(null);
+        restoredReader.addSplits(checkpoint);
+        assertThat(restoredReader.isAvailable()).isDone();
+
+        writeToTable(2, 2, 2);
+        TestingReaderOutput<Split> output = new TestingReaderOutput<>();
+        
assertThat(restoredReader.pollNext(output)).isEqualTo(InputStatus.NOTHING_AVAILABLE);
+        assertThat(output.getEmittedRecords()).hasSize(1);
+        assertThat(readSplit(output.getEmittedRecords().get(0)))
+                .containsExactlyInAnyOrder(Arrays.asList(2, 2, 2));
+    }
+
     @Test
     public void testReadOperator() throws Exception {
         ReadOperator readOperator =

Reply via email to