Skip to content

[Flink] Kvscan flink integration#3383

Open
polyzos wants to merge 16 commits into
apache:mainfrom
polyzos:kvscan-flink-integration
Open

[Flink] Kvscan flink integration#3383
polyzos wants to merge 16 commits into
apache:mainfrom
polyzos:kvscan-flink-integration

Conversation

@polyzos

@polyzos polyzos commented May 26, 2026

Copy link
Copy Markdown
Contributor

#3126 extended the java client to support kvscan for the live rocksdb table.

This PR integrates that functionality inside the flink connector

Introduces KvBatchSplit and KvBatchSplitState as a new split type in the Flink source, enabling the connector to perform bounded full-table scans on primary-key tables via the server-side KV scan API (FIP-17), rather than reading from snapshots.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comment thread website/docs/engine-flink/options.md Outdated
@polyzos
polyzos requested review from loserwang1024 and wuchong May 27, 2026 06:20
Comment thread website/docs/engine-flink/reads.md

- This is a **bounded** read. The source finishes once all buckets have been drained and does not continue reading the change-log.
- On task restart, each bucket is rescanned from scratch. Progress within a scan session is not checkpointed, because an expired or invalidated server-side session cannot be resumed from a mid-point.
- The feature is disabled by default (`false`). Without it, unbounded (streaming) reads on primary-key tables work as usual; bounded reads require the data-lake integration to be enabled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When disabled, unbounded (streaming) reads on primary-key tables continue to work as usual. Bounded reads require data-lake integration unless server-side KV scanning is enabled.

Comment thread website/docs/engine-flink/reads.md
```

### Limit Read
The Fluss source supports limiting reads for both primary-key tables and log tables, making it convenient to preview the latest `N` records in a table.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

making it easy to preview the latest N records in a table.

Comment thread website/docs/engine-flink/reads.md
Comment thread website/docs/engine-flink/reads.md
Comment thread website/docs/engine-flink/options.md Outdated
@polyzos

polyzos commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

@binary-signa Thank you for your comments... Addressed!

@binary-signal

binary-signal commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

@polyzos I had a look at the PR and can confirm that bounded reads via the Table API work as expected.
That said, there is an issue when performing bounded reads via the DataStream API. It looks like the corresponding logic was missed in FlussSourceBuilder.java. The required changes were already implemented in [flink] Add union read support to datastream (#3432) which allows setBounded() to be set in a Fluss Flink Source. The following guard throws:

# FlussSourceBuilder.java

if (bounded && !(lakeEnabled && fullStartup)) {
    ...
}

The following patch addresses the issue and also adds a test case to cover this scenario.

diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
index 2e80067f..eb95e731 100644
--- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
+++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/FlussSourceBuilder.java
@@ -176,7 +176,10 @@ public class FlussSourceBuilder<OUT> {
      * Builds a bounded source for batch execution. The source reads up to the latest offsets at job
      * startup and then finishes; combined with the default {@link OffsetsInitializer#full()} on a
      * datalake-enabled table this performs a bounded union read of the lake snapshot and the Fluss
-     * log. If not called, the source is unbounded (streaming).
+     * log. Alternatively, a bounded read of a primary-key table is supported via the server-side KV
+     * scan by setting {@code client.scanner.kv.server-side.enabled = true} (see {@link
+     * ConfigOptions#CLIENT_SCANNER_KV_SERVER_SIDE_ENABLED}). If not called, the source is unbounded
+     * (streaming).
      *
      * @return this builder
      */
@@ -353,13 +356,24 @@ public class FlussSourceBuilder<OUT> {
         boolean lakeEnabled = tableInfo.getTableConfig().isDataLakeEnabled();
         boolean fullStartup = offsetsInitializer instanceof SnapshotOffsetsInitializer;
 
-        if (bounded && !(lakeEnabled && fullStartup)) {
+        // bounded read via the server-side KV scan applies to primary-key tables when the master
+        // switch is enabled, mirroring the SQL connector (see FlinkTableSource).
+        boolean kvBatchEnabled = flussConf.get(ConfigOptions.CLIENT_SCANNER_KV_SERVER_SIDE_ENABLED);
+        boolean kvBatchAllowed = hasPrimaryKey && kvBatchEnabled;
+
+        if (bounded && !(lakeEnabled && fullStartup) && !kvBatchAllowed) {
             throw new IllegalArgumentException(
                     String.format(
-                            "Bounded (batch) read requires a datalake-enabled table started in "
-                                    + "full mode (OffsetsInitializer.full()), but table '%s' has "
-                                    + "datalake enabled=%s and full startup mode=%s.",
-                            tablePath, lakeEnabled, fullStartup));
+                            "Bounded (batch) read requires either a datalake-enabled table started "
+                                    + "in full mode (OffsetsInitializer.full()), or a primary-key "
+                                    + "table with '%s' = 'true'. Table '%s' has datalake enabled=%s, "
+                                    + "full startup mode=%s, primary key=%s, server-side KV scan=%s.",
+                            ConfigOptions.CLIENT_SCANNER_KV_SERVER_SIDE_ENABLED.key(),
+                            tablePath,
+                            lakeEnabled,
+                            fullStartup,
+                            hasPrimaryKey,
+                            kvBatchEnabled));
         }
 
         LakeSource<LakeSplit> lakeSource = null;
diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/FlussSourceITCase.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/FlussSourceITCase.java
index f4b112c4..df211643 100644
--- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/FlussSourceITCase.java
+++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/FlussSourceITCase.java
@@ -21,6 +21,8 @@ import org.apache.fluss.client.initializer.OffsetsInitializer;
 import org.apache.fluss.client.table.Table;
 import org.apache.fluss.client.table.writer.AppendWriter;
 import org.apache.fluss.client.table.writer.UpsertWriter;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
 import org.apache.fluss.flink.source.deserializer.RowDataDeserializationSchema;
 import org.apache.fluss.flink.source.testutils.MockDataUtils;
 import org.apache.fluss.flink.source.testutils.Order;
@@ -113,6 +115,37 @@ public class FlussSourceITCase extends FlinkTestBase {
         assertThat(collectedElements).hasSameElementsAs(ORDERS);
     }
 
+    @Test
+    public void testBoundedKvBatchPKSource() throws Exception {
+        createTable(ordersPKTablePath, pkTableDescriptor);
+        writeRowsToTable(ordersPKTablePath);
+
+        // Enable the server-side KV scan so a bounded read is allowed on a primary-key table
+        // without the data-lake integration (mirrors the SQL connector's behavior).
+        Configuration flussConf = new Configuration();
+        flussConf.set(ConfigOptions.CLIENT_SCANNER_KV_SERVER_SIDE_ENABLED, true);
+
+        FlussSource<Order> flussSource =
+                FlussSource.<Order>builder()
+                        .setBootstrapServers(bootstrapServers)
+                        .setDatabase(DEFAULT_DB)
+                        .setTable(pkTableName)
+                        .setStartingOffsets(OffsetsInitializer.full())
+                        .setScanPartitionDiscoveryIntervalMs(1000L)
+                        .setDeserializationSchema(new MockDataUtils.OrderDeserializationSchema())
+                        .setFlussConfig(flussConf)
+                        .setBounded()
+                        .build();
+
+        // env.setRuntimeMode(RuntimeExecutionMode.BATCH);
+        DataStreamSource<Order> stream =
+                env.fromSource(flussSource, WatermarkStrategy.noWatermarks(), "Fluss Source");
+
+        List<Order> collectedElements = stream.executeAndCollect(ORDERS.size());
+
+        assertThat(collectedElements).hasSameElementsAs(ORDERS);
+    }
+
     @Test
     public void testTablePKSourceWithProjectionPushdown() throws Exception {
         createTable(ordersPKTablePath, pkTableDescriptor);

Quick example to reproduce

create a primary key table with datalake option disabled

 CREATE DATABASE cdstream_db;

 CREATE TABLE `fluss_catalog`.`cdstream_db`.`timeseries_norm` (
  `streamId` INT NOT NULL,
  `vector` ARRAY<DOUBLE>,
  `firstTimestamp` TIMESTAMP(3),
  `lastTimestamp` TIMESTAMP(3),
  `lastUpdateTime` TIMESTAMP(3),
  CONSTRAINT `PK_streamId` PRIMARY KEY (`streamId`) NOT ENFORCED
)
COMMENT 'This is the normalized timeseries table'
WITH (
  'bootstrap.servers' = 'coordinator-server:9123',
  'bucket.key' = 'streamId',
  'bucket.num' = '2',
  'table.datalake.enabled' = 'false',
  'table.datalake.format' = 'paimon'
)

Then try bounded read via DataStream API it will throw

import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.data.RowData;
import org.apache.fluss.config.Configuration;
import org.apache.fluss.flink.source.FlussSource;
import org.apache.fluss.flink.source.deserializer.RowDataDeserializationSchema;

public class FlussDataStreamBoundedReadKvBatch {
    public static void main(String[] args) throws Exception {

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();


        // env.setRuntimeMode(RuntimeExecutionMode.STREAMING);
        Configuration conf = new Configuration();
        conf.setBoolean("client.scanner.kv.server-side.enabled", true);
        var source = FlussSource.<RowData>builder()
                .setBootstrapServers("localhost:9122")
                .setDatabase("cdstream_db")
                .setTable("timeseries_norm")
                .setDeserializationSchema(new RowDataDeserializationSchema())
                .setBounded()
                .setFlussConfig(conf)
                .build();

        DataStream<RowData> stream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "BoundedFlussKvSource");

        stream.print();
        env.execute("BoundedFlussKvJob");
    }
}

@polyzos

polyzos commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

@binary-signal yes I need to find some time to rebase, and after we also need to introduce partition pruning and predicate pushdown tot Datastream.

I have tickets to track these

@polyzos
polyzos force-pushed the kvscan-flink-integration branch from 962a847 to 9bec12b Compare July 13, 2026 18:14

@fresh-borzoni fresh-borzoni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@polyzos Thank you for the PR, left a couple of comments, PTAL

}

@Test
void testKvBatchScanOnPkTable() throws Exception {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All KV tests write each key exactly once, so a log read would return the same rows and still pass. Shall we add an update + a delete before the scan, so the test can tell KV state from the changelog?

} else {
splits = this.getLogSplit(null, null);
}
splits = generateFlussOnlyBatchSplits(kvBatchEnabled);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With lake enabled but no lake snapshot yet, flag off reads the whole log, so updates and deletes come back as extra rows (-U/+U/-D), and SELECT * returns the history, not the table.
Shall we KV-scan PK tables here regardless of the flag or fail? Silently wrong rows as the default seems wrong. WDYT?

.withDescription(
"Master switch for using the server-side KV scan in bounded reads "
+ "of primary-key tables. When true, bounded primary-key reads "
+ "scan the current KV state directly via the server-side KV scan. "

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"scan the current KV state directly" reads as if the flag forces a KV scan, but when a lake snapshot exists the union read still wins and the flag is never consulted and it only really applies when there's no lake data yet.

Shall we add "has no effect when a lake snapshot already exists" or smth similar? Same- in options.md.

@polyzos

polyzos commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

@fresh-borzoni Thank you for your review and comments.
They all make sense, and I pushed some changes to address them. Let me know your thoughts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants