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 1747cc2347 [flink] Expose Flink Source in FlinkSourceBuilder API 
(#10713)
1747cc2347 is described below

commit 1747cc2347dfa84984af4f77bc19c59a624ca3df
Author: eye-gu <[email protected]>
AuthorDate: Mon Oct 26 12:18:15 2026 +0800

    [flink] Expose Flink Source in FlinkSourceBuilder API (#10713)
---
 .../paimon/flink/source/FlinkSourceBuilder.java    | 161 ++++++++++++++++-----
 .../flink/source/FlinkSourceBuilderTest.java       | 150 ++++++++++++++++++-
 .../paimon/flink/source/HybridSourceITCase.java    | 135 +++++++++++++++++
 3 files changed, 409 insertions(+), 37 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
index 0b73413b87..4857d40c13 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkSourceBuilder.java
@@ -217,20 +217,68 @@ public class FlinkSourceBuilder {
         return readBuilder.dropStats();
     }
 
-    private DataStream<RowData> buildStaticFileSource() {
-        Options options = Options.fromMap(table.options());
-        return toDataStream(
-                new StaticFileStoreSource(
-                        createReadBuilder(projectedRowType()),
-                        limit,
-                        
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_BATCH_SIZE),
-                        
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_ASSIGN_MODE),
-                        dynamicPartitionFilteringInfo,
-                        outerProject(),
-                        SplitWeightUtils.splitWeightFunc(options),
-                        null,
-                        options.get(CoreOptions.BLOB_AS_DESCRIPTOR),
-                        skipPreloadTargetSnapshot));
+    private Source<RowData, ?, ?> createSource() {
+        checkConsumerId();
+        if (sourceBounded == null) {
+            throw new IllegalArgumentException(
+                    "Cannot determine boundedness of source: please provide 
the "
+                            + "StreamExecutionEnvironment via env() or specify 
sourceBounded().");
+        }
+
+        if (sourceBounded) {
+            if (PostponeMergeOnRead.usesCustomSource(table)) {
+                throw new UnsupportedOperationException(
+                        "Postpone merge-on-read relies on a custom 
multi-operator topology, "
+                                + "please use build() instead.");
+            }
+            if 
(conf.get(FlinkConnectorOptions.SCAN_DEDICATED_SPLIT_GENERATION)) {
+                throw new UnsupportedOperationException(
+                        "scan.dedicated-split-generation relies on a monitor 
topology, "
+                                + "please use build() instead.");
+            }
+            Options options = Options.fromMap(table.options());
+            return new StaticFileStoreSource(
+                    createReadBuilder(projectedRowType()),
+                    limit,
+                    
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_BATCH_SIZE),
+                    
options.get(FlinkConnectorOptions.SCAN_SPLIT_ENUMERATOR_ASSIGN_MODE),
+                    dynamicPartitionFilteringInfo,
+                    outerProject(),
+                    SplitWeightUtils.splitWeightFunc(options),
+                    null,
+                    options.get(CoreOptions.BLOB_AS_DESCRIPTOR),
+                    skipPreloadTargetSnapshot);
+        }
+
+        TableScanUtils.streamingReadingValidate(table);
+        if (conf.get(FlinkConnectorOptions.SOURCE_CHECKPOINT_ALIGN_ENABLED)) {
+            throw new UnsupportedOperationException(
+                    "Checkpoint align mode requires validation against the 
environment, "
+                            + "please use build() instead.");
+        }
+        if (conf.contains(CoreOptions.CONSUMER_ID)
+                && conf.get(CoreOptions.CONSUMER_CONSISTENCY_MODE)
+                        == CoreOptions.ConsumerMode.EXACTLY_ONCE) {
+            throw new UnsupportedOperationException(
+                    "Exactly-once consumer mode relies on a monitor topology, "
+                            + "please use build() instead.");
+        }
+        return new ContinuousFileStoreSource(
+                createReadBuilder(projectedRowType()),
+                table.options(),
+                limit,
+                unordered,
+                outerProject());
+    }
+
+    private void checkConsumerId() {
+        if (conf.contains(CoreOptions.CONSUMER_ID)
+                && !conf.contains(CoreOptions.CONSUMER_EXPIRATION_TIME)) {
+            throw new IllegalArgumentException(
+                    "You need to configure 'consumer.expiration-time' (ALTER 
TABLE) and restart your write job for it"
+                            + " to take effect, when you need consumer-id 
feature. This is to prevent consumers from leaving"
+                            + " too many snapshots that could pose a risk to 
the file system.");
+        }
     }
 
     private @Nullable DataStream<RowData> buildPostponeMergeSource() {
@@ -330,16 +378,6 @@ public class FlinkSourceBuilder {
         return conf.get(FlinkConnectorOptions.INFER_SCAN_PARALLELISM);
     }
 
-    private DataStream<RowData> buildContinuousFileSource() {
-        return toDataStream(
-                new ContinuousFileStoreSource(
-                        createReadBuilder(projectedRowType()),
-                        table.options(),
-                        limit,
-                        unordered,
-                        outerProject()));
-    }
-
     private DataStream<RowData> buildAlignedContinuousFileSource() {
         assertStreamingConfigurationForAlignMode(env);
         return toDataStream(
@@ -418,18 +456,75 @@ public class FlinkSourceBuilder {
         return result;
     }
 
+    /**
+     * Build a {@link Source} for APIs requiring a plain source instance, such 
as Flink's {@code
+     * HybridSource}. Different from {@link #build()}, no {@link 
StreamExecutionEnvironment} is
+     * required: boundedness is either inferred from the environment set via 
{@link
+     * #env(StreamExecutionEnvironment)} or must be specified with {@link 
#sourceBounded(boolean)}.
+     *
+     * <p>When a Paimon bounded source is the first (historical) segment of a 
{@code HybridSource}
+     * in a streaming job, {@code sourceBounded(true)} must be set explicitly, 
because {@link
+     * #env(StreamExecutionEnvironment)} infers unboundedness from a streaming 
environment.
+     *
+     * <p>Example:
+     *
+     * <pre>{@code
+     * Source<RowData, ?, ?> bounded =
+     *         new FlinkSourceBuilder(table).sourceBounded(true).buildSource();
+     * HybridSource<RowData> hybrid =
+     *         HybridSource.builder(bounded)
+     *                 .addSource(
+     *                         context ->
+     *                                 new FlinkSourceBuilder(table)
+     *                                         .sourceBounded(false)
+     *                                         .buildSource(),
+     *                         Boundedness.CONTINUOUS_UNBOUNDED)
+     *                 .build();
+     *
+     * // env is the StreamExecutionEnvironment the job runs on
+     * env.fromSource(
+     *         hybrid,
+     *         WatermarkStrategy.noWatermarks(),
+     *         "hybrid",
+     *         
InternalTypeInfo.of(LogicalTypeConversion.toLogicalType(table.rowType())));
+     * }</pre>
+     *
+     * <p>The second (streaming) segment is created when {@code HybridSource} 
switches to it, and
+     * its start position is resolved at that moment. There is no hand-off 
with the bounded segment:
+     * {@code scan.mode=latest} reads only newer changes, so changes committed 
after the bounded
+     * segment planned its splits but before the switch are skipped; the 
default {@code latest-full}
+     * re-reads the whole table first, so nothing is lost but the bounded 
segment's data is
+     * duplicated.
+     *
+     * <p>Only the default static and continuous scan paths are supported; 
postpone merge-on-read,
+     * {@code scan.dedicated-split-generation}, {@code 
source.checkpoint-align.enabled} and
+     * exactly-once consumer mode rely on other topologies and require {@link 
#build()}. The
+     * exception is postpone merge-on-read with {@code 
scan.mode=compacted-full}, which reads
+     * through the static path.
+     *
+     * <p>{@link #sourceName(String)}, {@link 
#watermarkStrategy(WatermarkStrategy)}, {@link
+     * #sourceParallelism(Integer)} and the uid suffix option are ignored by 
this method; apply the
+     * source name and the watermark strategy in your {@code 
env.fromSource(...)} call, and the
+     * parallelism and uid on the resulting {@code DataStreamSource}. A job 
that relies on the uid
+     * suffix for state compatibility will not restore from state when 
switching to this method.
+     *
+     * <p>{@code HybridSource} does not implement {@code ResultTypeQueryable}, 
so the type
+     * information must be passed to {@code env.fromSource(...)} explicitly, 
for example {@code
+     * InternalTypeInfo.of(LogicalTypeConversion.toLogicalType(rowType))} 
where {@code rowType} is
+     * {@code table.rowType()}, or the projected row type if {@code 
projection(...)} is used. Note
+     * that {@code InternalTypeInfo} is a Flink internal API with no public 
alternative for {@code
+     * RowData} type information.
+     */
+    public Source<RowData, ?, ?> buildSource() {
+        return new PaimonDataStreamSource<>(createSource(), table);
+    }
+
     /** Build source {@link DataStream} with {@link RowData}. */
     public DataStream<RowData> build() {
         if (env == null) {
             throw new IllegalArgumentException("StreamExecutionEnvironment 
should not be null.");
         }
-        if (conf.contains(CoreOptions.CONSUMER_ID)
-                && !conf.contains(CoreOptions.CONSUMER_EXPIRATION_TIME)) {
-            throw new IllegalArgumentException(
-                    "You need to configure 'consumer.expiration-time' (ALTER 
TABLE) and restart your write job for it"
-                            + " to take effect, when you need consumer-id 
feature. This is to prevent consumers from leaving"
-                            + " too many snapshots that could pose a risk to 
the file system.");
-        }
+        checkConsumerId();
 
         if (sourceBounded && PostponeMergeOnRead.configured(table)) {
             DataStream<RowData> mergeSource = buildPostponeMergeSource();
@@ -442,7 +537,7 @@ public class FlinkSourceBuilder {
             if 
(conf.get(FlinkConnectorOptions.SCAN_DEDICATED_SPLIT_GENERATION)) {
                 return buildDedicatedSplitGenSource(true);
             }
-            return buildStaticFileSource();
+            return toDataStream(createSource());
         }
         TableScanUtils.streamingReadingValidate(table);
 
@@ -459,7 +554,7 @@ public class FlinkSourceBuilder {
                         == CoreOptions.ConsumerMode.EXACTLY_ONCE) {
             return buildDedicatedSplitGenSource(false);
         } else {
-            return buildContinuousFileSource();
+            return toDataStream(createSource());
         }
     }
 
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkSourceBuilderTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkSourceBuilderTest.java
index 9e891b12df..5ade069c36 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkSourceBuilderTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkSourceBuilderTest.java
@@ -38,7 +38,9 @@ import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 
+import org.apache.flink.api.common.RuntimeExecutionMode;
 import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.Source;
 import org.apache.flink.api.dag.Transformation;
 import org.apache.flink.runtime.plugable.SerializationDelegate;
 import org.apache.flink.streaming.api.datastream.DataStream;
@@ -99,11 +101,23 @@ public class FlinkSourceBuilderTest {
     private Table createTable(
             String tableName, boolean hasPrimaryKey, int bucketNum, boolean 
bucketAppendOrdered)
             throws Exception {
+        return createTableWithOptions(
+                tableName,
+                hasPrimaryKey,
+                bucketNum,
+                "bucket-append-ordered",
+                String.valueOf(bucketAppendOrdered));
+    }
+
+    private Table createTableWithOptions(
+            String tableName, boolean hasPrimaryKey, int bucketNum, String... 
kvOptions)
+            throws Exception {
         Schema.Builder schemaBuilder =
-                Schema.newBuilder()
-                        .column("a", DataTypes.INT())
-                        .option("bucket", bucketNum + "")
-                        .option("bucket-append-ordered", 
String.valueOf(bucketAppendOrdered));
+                Schema.newBuilder().column("a", 
DataTypes.INT()).option("bucket", bucketNum + "");
+
+        for (int i = 0; i < kvOptions.length; i += 2) {
+            schemaBuilder.option(kvOptions[i], kvOptions[i + 1]);
+        }
 
         if (hasPrimaryKey) {
             schemaBuilder.primaryKey("a");
@@ -304,6 +318,134 @@ public class FlinkSourceBuilderTest {
         
assertThat(transformation.getSource()).isInstanceOf(PaimonDataStreamSource.class);
     }
 
+    @Test
+    public void testBuildSourceWithoutEnv() throws Exception {
+        Table table = createTable("build_source", false, -1, true);
+
+        Source<RowData, ?, ?> bounded =
+                new 
FlinkSourceBuilder(table).sourceBounded(true).buildSource();
+        assertThat(bounded).isInstanceOf(PaimonDataStreamSource.class);
+        assertThat(bounded.getBoundedness()).isEqualTo(Boundedness.BOUNDED);
+
+        Source<RowData, ?, ?> unbounded =
+                new 
FlinkSourceBuilder(table).sourceBounded(false).buildSource();
+        assertThat(unbounded).isInstanceOf(PaimonDataStreamSource.class);
+        
assertThat(unbounded.getBoundedness()).isEqualTo(Boundedness.CONTINUOUS_UNBOUNDED);
+    }
+
+    @Test
+    public void testBuildSourceInfersBoundednessFromEnv() throws Exception {
+        Table table = createTable("build_source_env", false, -1, true);
+
+        StreamExecutionEnvironment batchEnv = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        batchEnv.setRuntimeMode(RuntimeExecutionMode.BATCH);
+        Source<RowData, ?, ?> bounded = new 
FlinkSourceBuilder(table).env(batchEnv).buildSource();
+        assertThat(bounded.getBoundedness()).isEqualTo(Boundedness.BOUNDED);
+
+        StreamExecutionEnvironment streamingEnv =
+                StreamExecutionEnvironment.getExecutionEnvironment();
+        streamingEnv.setRuntimeMode(RuntimeExecutionMode.STREAMING);
+        Source<RowData, ?, ?> unbounded =
+                new FlinkSourceBuilder(table).env(streamingEnv).buildSource();
+        
assertThat(unbounded.getBoundedness()).isEqualTo(Boundedness.CONTINUOUS_UNBOUNDED);
+    }
+
+    @Test
+    public void testBuildSourceWithoutBoundedness() throws Exception {
+        Table table = createTable("build_source_no_boundedness", false, -1, 
true);
+
+        assertThatThrownBy(() -> new FlinkSourceBuilder(table).buildSource())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("sourceBounded");
+    }
+
+    @Test
+    public void testBuildSourceRejectsPostponeMerge() throws Exception {
+        Table table =
+                createTableWithOptions(
+                        "build_source_postpone_merge", true, -2, 
"postpone.merge-on-read", "true");
+
+        assertThatThrownBy(() -> new 
FlinkSourceBuilder(table).sourceBounded(true).buildSource())
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("build()");
+    }
+
+    @Test
+    public void testBuildSourceSupportsPostponeMergeCompactedFull() throws 
Exception {
+        // COMPACTED_FULL intentionally falls back to the static source in 
build(),
+        // so buildSource() must not reject it either
+        Table table =
+                createTableWithOptions(
+                        "build_source_postpone_merge_compacted_full",
+                        true,
+                        -2,
+                        "postpone.merge-on-read",
+                        "true",
+                        "scan.mode",
+                        "compacted-full");
+
+        Source<RowData, ?, ?> source =
+                new 
FlinkSourceBuilder(table).sourceBounded(true).buildSource();
+        assertThat(source).isInstanceOf(PaimonDataStreamSource.class);
+        assertThat(source.getBoundedness()).isEqualTo(Boundedness.BOUNDED);
+
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        DataStream<RowData> dataStream =
+                new 
FlinkSourceBuilder(table).env(env).sourceBounded(true).build();
+        
assertThat(dataStream.getTransformation()).isInstanceOf(SourceTransformation.class);
+        SourceTransformation<?, ?, ?> transformation =
+                (SourceTransformation<?, ?, ?>) dataStream.getTransformation();
+        
assertThat(transformation.getSource()).isInstanceOf(PaimonDataStreamSource.class);
+        
assertThat(transformation.getSource().getBoundedness()).isEqualTo(Boundedness.BOUNDED);
+    }
+
+    @Test
+    public void testBuildSourceRejectsDedicatedSplitGeneration() throws 
Exception {
+        Table table =
+                createTableWithOptions(
+                        "build_source_dedicated_split",
+                        false,
+                        -1,
+                        "scan.dedicated-split-generation",
+                        "true");
+
+        assertThatThrownBy(() -> new 
FlinkSourceBuilder(table).sourceBounded(true).buildSource())
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("build()");
+    }
+
+    @Test
+    public void testBuildSourceRejectsCheckpointAlign() throws Exception {
+        Table table =
+                createTableWithOptions(
+                        "build_source_checkpoint_align",
+                        false,
+                        -1,
+                        "source.checkpoint-align.enabled",
+                        "true");
+
+        assertThatThrownBy(() -> new 
FlinkSourceBuilder(table).sourceBounded(false).buildSource())
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("build()");
+    }
+
+    @Test
+    public void testBuildSourceRejectsExactlyOnceConsumer() throws Exception {
+        Table table =
+                createTableWithOptions(
+                        "build_source_exactly_once_consumer",
+                        false,
+                        -1,
+                        "consumer-id",
+                        "my-consumer",
+                        "consumer.expiration-time",
+                        "1 h");
+
+        assertThatThrownBy(() -> new 
FlinkSourceBuilder(table).sourceBounded(false).buildSource())
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("build()");
+    }
+
     @Test
     public void testPostponeMergeOnReadIgnoresContinuousSource() throws 
Exception {
         Identifier identifier = Identifier.create("default", 
"postpone_merge_on_read");
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/HybridSourceITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/HybridSourceITCase.java
new file mode 100644
index 0000000000..0598e64a0f
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/HybridSourceITCase.java
@@ -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.flink.source;
+
+import org.apache.paimon.flink.CatalogITCaseBase;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.table.FileStoreTable;
+
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.Source;
+import org.apache.flink.connector.base.source.hybrid.HybridSource;
+import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.table.api.TableResult;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.util.DataFormatConverters;
+import org.apache.flink.table.runtime.typeutils.ExternalTypeInfo;
+import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.types.Row;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static 
org.apache.flink.table.types.utils.TypeConversions.fromLogicalToDataType;
+import static org.apache.paimon.flink.LogicalTypeConversion.toLogicalType;
+
+/** End-to-end test of {@link FlinkSourceBuilder#buildSource()} embedded in a 
HybridSource. */
+public class HybridSourceITCase extends CatalogITCaseBase {
+
+    @Override
+    protected List<String> ddl() {
+        return Arrays.asList(
+                "CREATE TABLE hybrid_source (a INT, b STRING)",
+                "CREATE TABLE hybrid_sink (a INT, b STRING)");
+    }
+
+    @Timeout(300)
+    @Test
+    public void testBoundedThenContinuousRead() throws Exception {
+        batchSql("INSERT INTO hybrid_source VALUES (1, 'first'), (2, 
'second')");
+
+        FileStoreTable table = paimonTable("hybrid_source");
+
+        StreamExecutionEnvironment env =
+                streamExecutionEnvironmentBuilder()
+                        .streamingMode()
+                        .parallelism(2)
+                        .checkpointIntervalMs(500)
+                        .build();
+        StreamTableEnvironment streamTableEnv = 
StreamTableEnvironment.create(env);
+        streamTableEnv.registerCatalog("PAIMON_STREAM", 
sEnv.getCatalog("PAIMON").get());
+        streamTableEnv.executeSql("USE CATALOG PAIMON_STREAM");
+
+        Source<RowData, ?, ?> bounded =
+                new 
FlinkSourceBuilder(table).sourceBounded(true).buildSource();
+        // The continuous segment (latest-full) would re-read the bounded 
segment's data, so it
+        // filters a <= 2: rows 1-2 can then only be delivered by the bounded 
segment.
+        Predicate batch2Only = new 
PredicateBuilder(table.rowType()).greaterThan(0, 2);
+        HybridSource<RowData> hybridSource =
+                HybridSource.builder(bounded)
+                        .addSource(
+                                context ->
+                                        new FlinkSourceBuilder(table)
+                                                .predicate(batch2Only)
+                                                .sourceBounded(false)
+                                                .buildSource(),
+                                Boundedness.CONTINUOUS_UNBOUNDED)
+                        .build();
+
+        DataType dataType = 
fromLogicalToDataType(toLogicalType(table.rowType()));
+        DataFormatConverters.RowConverter converter =
+                new DataFormatConverters.RowConverter(
+                        dataType.getChildren().toArray(new DataType[0]));
+        DataStream<Row> rows =
+                env.fromSource(
+                                hybridSource,
+                                WatermarkStrategy.noWatermarks(),
+                                "HybridSource",
+                                
InternalTypeInfo.of(toLogicalType(table.rowType())))
+                        .map((MapFunction<RowData, Row>) converter::toExternal)
+                        .returns(ExternalTypeInfo.of(dataType));
+
+        streamTableEnv.createTemporaryView("S", 
streamTableEnv.fromDataStream(rows).as("a", "b"));
+        TableResult result = streamTableEnv.executeSql("INSERT INTO 
hybrid_sink SELECT * FROM S");
+        JobClient client = result.getJobClient().get();
+
+        try {
+            // written after the job is submitted; the continuous segment sees 
it either in its
+            // starting snapshot or incrementally, so both segments are still 
verified
+            batchSql("INSERT INTO hybrid_source VALUES (3, 'third'), (4, 
'fourth')");
+
+            // DISTINCT tolerates batch2 being read by both segments, but rows 
1-2 are filtered
+            // out of the continuous segment and thus prove the bounded 
segment ran
+            sqlAssertWithRetry(
+                    "SELECT DISTINCT * FROM hybrid_sink",
+                    assertion ->
+                            assertion.containsExactlyInAnyOrder(
+                                    Row.of(1, "first"),
+                                    Row.of(2, "second"),
+                                    Row.of(3, "third"),
+                                    Row.of(4, "fourth")));
+        } finally {
+            try {
+                client.cancel().get(30, TimeUnit.SECONDS);
+            } catch (Exception ignored) {
+                // best-effort cleanup: the job may already be terminated
+            }
+        }
+    }
+}

Reply via email to