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 74018bd672 [flink] Support stream read Chain Table (#8262)
74018bd672 is described below
commit 74018bd6723448fb14e7f8710ba65fdac2d0eefe
Author: yunfengzhou-hub <[email protected]>
AuthorDate: Mon Jun 29 15:04:55 2026 +0800
[flink] Support stream read Chain Table (#8262)
Chain Table (`chain-table.enabled=true`) separates data into a
`snapshot` branch (batch-imported full partitions) and a `delta` branch
(incremental updates). Prior to this change, streaming read was not
supported because the standard `DataTableStreamScan` is unaware of the
two-branch architecture.
This PR introduces `ChainTableFileStoreTable` (a wrapper over
`FallbackReadFileStoreTable`) and `ChainTableStreamScan` which
implements a two-phase streaming scan: Phase 1 does a full load by
reading delta data pinned to the current snapshot and merging snapshot
files for overlapping partitions; Phase 2 incrementally monitors the
delta branch only, returning `DataSplit(isStreaming=true)` for changelog
passthrough. The snapshot-pinning strategy makes the Phase 1 / Phase 2
boundary deterministic — no overlap or data loss regardless of
concurrent commits.
---
docs/docs/primary-key-table/chain-table.mdx | 50 +-
.../apache/paimon/table/ChainGroupReadTable.java | 7 +-
.../paimon/table/ChainTableFileStoreTable.java | 219 +++
.../apache/paimon/table/ChainTableStreamScan.java | 494 ++++++
.../apache/paimon/table/FileStoreTableFactory.java | 2 +-
.../org/apache/paimon/table/source/ChainSplit.java | 18 +
.../source/ContinuousFileSplitEnumerator.java | 32 +
.../paimon/flink/source/FlinkSourceBuilder.java | 6 +
.../apache/paimon/flink/FlinkChainTableITCase.java | 1699 ++++++++++++++++++++
9 files changed, 2523 insertions(+), 4 deletions(-)
diff --git a/docs/docs/primary-key-table/chain-table.mdx
b/docs/docs/primary-key-table/chain-table.mdx
index dee23bb36d..ada14470ec 100644
--- a/docs/docs/primary-key-table/chain-table.mdx
+++ b/docs/docs/primary-key-table/chain-table.mdx
@@ -129,7 +129,6 @@ ALTER TABLE `default`.`t$branch_delta` SET (
Notice that:
- Chain table is only supported for primary key table, which means you should
define `bucket` and `bucket-key` for the table.
- Chain table should ensure that the schema of each branch is consistent.
-- Both Spark and Flink batch read/write are supported. Flink streaming
read/write is not supported.
- Deletion vector is not supported for chain table.
After creating a chain table, you can read and write data in the following
ways.
@@ -216,6 +215,55 @@ you will get the following result:
+---+----+-----+
```
+## Streaming Read
+
+Chain tables support Flink streaming read. A streaming read job operates in
two phases:
+
+1. **Full load phase**: Produces a full result by reading the latest snapshot
partition (per group)
+ and delta partitions that come after it. For each partition group, only the
most recent snapshot
+ partition is included — older snapshot partitions are considered outdated
and excluded.
+2. **Incremental phase**: Continuously reads new commits from the delta branch
as they arrive.
+
+### Write-Side Requirements
+
+Streaming read assumes the chain table follows the standard write pattern
described at the top of
+this page:
+
+- **Snapshot branch** receives periodic full data (e.g., a daily ODS binlog
dump job writes via
+ `INSERT OVERWRITE t$branch_snapshot`). Each snapshot partition represents a
complete view of the
+ data at that point in time.
+- **Delta branch** receives incremental changes between snapshots (e.g., a
batch job writes the
+ current day's new/updated records via `INSERT INTO t$branch_delta`). Each
delta partition
+ contains only the changes for that period.
+
+The streaming read relies on this pattern to produce correct results. After
the full load phase,
+only new delta branch commits are picked up — writes to the snapshot branch do
not trigger
+streaming output. To incorporate a new snapshot, restart the streaming job.
+
+### Usage
+
+```sql
+SET 'execution.runtime-mode' = 'streaming';
+
+INSERT INTO downstream_sink SELECT * FROM default.t;
+```
+
+### Limitations
+
+- The incremental phase only monitors the **delta branch**. Writes to the
snapshot branch are
+ not detected until the streaming job is restarted.
+- The chain-table-aware streaming scan only supports the default startup mode
(`latest-full`).
+ When the user specifies an explicit starting position — such as
`scan.snapshot-id`,
+ `scan.timestamp-millis`, `scan.mode = 'latest'`, or `consumer-id` — an
+ `UnsupportedOperationException` is thrown. To use standard streaming read
without chain table
+ logic, read from a specific branch table (e.g., `t$branch_delta`) instead of
the main table.
+- Partition filters are not supported in chain table streaming reads.
Specifying a partition
+ filter — either via a `WHERE` clause on partition columns or the
`scan.partitions` table
+ option — throws an `UnsupportedOperationException`. This is because the
chain table streaming
+ scan determines which partitions to read based on the chain-merge logic
across snapshot and
+ delta branches, and applying a partition filter would interfere with this
logic. To read a
+ specific partition, use batch mode instead.
+
## Group Partition
In real-world scenarios, a table often has multiple partition dimensions. For
example, data may be
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
index 95a591ebb4..c65c287aec 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/ChainGroupReadTable.java
@@ -535,8 +535,11 @@ public class ChainGroupReadTable extends
FallbackReadFileStoreTable {
@Override
public RecordReader<InternalRow> createReader(Split split) throws
IOException {
- checkArgument(split instanceof ChainSplit);
- return fallbackRead.createReader(split);
+ if (split instanceof ChainSplit || split instanceof DataSplit) {
+ return fallbackRead.createReader(split);
+ }
+ throw new IllegalArgumentException(
+ "Unsupported split type for chain table read: " +
split.getClass().getName());
}
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/ChainTableFileStoreTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/ChainTableFileStoreTable.java
new file mode 100644
index 0000000000..863cd969bc
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/ChainTableFileStoreTable.java
@@ -0,0 +1,219 @@
+/*
+ * 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.table;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.CoreOptions.StartupMode;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.source.ChainSplit;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.InnerTableRead;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.StreamDataTableScan;
+import org.apache.paimon.table.source.TableRead;
+import org.apache.paimon.types.RowType;
+
+import java.io.IOException;
+import java.util.Map;
+
+/**
+ * Chain-table-aware extension of {@link FallbackReadFileStoreTable}. Inherits
the batch read
+ * behavior (partition-level fallback between the current branch and {@link
ChainGroupReadTable}),
+ * and additionally overrides {@link #newStreamScan()} to return a chain-aware
{@link
+ * ChainTableStreamScan} that performs a partition-level full load followed by
incremental
+ * delta-only streaming.
+ */
+public class ChainTableFileStoreTable extends FallbackReadFileStoreTable {
+
+ public ChainTableFileStoreTable(FileStoreTable wrapped, FileStoreTable
other) {
+ super(wrapped, other, true);
+ }
+
+ @Override
+ public StreamDataTableScan newStreamScan() {
+ CoreOptions coreOptions = wrapped.coreOptions();
+
+ StartupMode effectiveMode = coreOptions.startupMode();
+ boolean hasConsumer = coreOptions.consumerId() != null;
+ if (effectiveMode != StartupMode.LATEST_FULL || hasConsumer) {
+ String reason = describeUnsupportedMode(coreOptions,
effectiveMode, hasConsumer);
+ throw new UnsupportedOperationException(
+ "Chain table streaming read does not support startup mode
'"
+ + reason
+ + "'. "
+ + "Chain table streaming only supports the default
'latest-full' mode, which first "
+ + "produces a partition-level full result and then
continuously reads incremental "
+ + "data from the delta branch.\n"
+ + "Suggestions:\n"
+ + " - To use chain table streaming: remove the
explicit scan mode/position settings "
+ + "so that the default 'latest-full' mode is
used.\n"
+ + " - To use standard streaming read without
chain table logic: read from a "
+ + "specific branch table (e.g., 't$branch_delta')
instead of the main table.");
+ }
+
+ // Inherited other() returns the ChainGroupReadTable directly.
+ ChainGroupReadTable chainGroupReadTable = (ChainGroupReadTable)
other();
+
+ return new ChainTableStreamScan(chainGroupReadTable);
+ }
+
+ private static String describeUnsupportedMode(
+ CoreOptions coreOptions, StartupMode effectiveMode, boolean
hasConsumer) {
+ if (hasConsumer) {
+ return "consumer mode (consumer-id='" + coreOptions.consumerId() +
"')";
+ }
+ switch (effectiveMode) {
+ case LATEST:
+ return "scan.mode=latest";
+ case FROM_SNAPSHOT:
+ if (coreOptions.scanSnapshotId() != null) {
+ return "scan.snapshot-id=" + coreOptions.scanSnapshotId();
+ }
+ if (coreOptions.scanTagName() != null) {
+ return "scan.tag-name=" + coreOptions.scanTagName();
+ }
+ if (coreOptions.scanWatermark() != null) {
+ return "scan.watermark=" + coreOptions.scanWatermark();
+ }
+ return "from-snapshot";
+ case FROM_TIMESTAMP:
+ if (coreOptions.scanTimestampMills() != null) {
+ return "scan.timestamp-millis=" +
coreOptions.scanTimestampMills();
+ }
+ if (coreOptions.scanTimestamp() != null) {
+ return "scan.timestamp=" + coreOptions.scanTimestamp();
+ }
+ return "from-timestamp";
+ default:
+ return effectiveMode.name().toLowerCase().replace('_', '-');
+ }
+ }
+
+ @Override
+ public InnerTableRead newRead() {
+ return new ChainTableRead();
+ }
+
+ @Override
+ public FileStoreTable copy(Map<String, String> dynamicOptions) {
+ return new ChainTableFileStoreTable(
+ wrapped.copy(dynamicOptions),
other().copy(rewriteOtherOptions(dynamicOptions)));
+ }
+
+ @Override
+ public FileStoreTable copy(TableSchema newTableSchema) {
+ return new ChainTableFileStoreTable(
+ wrapped.copy(newTableSchema),
+
other().copy(newTableSchema.copy(rewriteOtherOptions(newTableSchema.options()))));
+ }
+
+ @Override
+ public FileStoreTable copyWithoutTimeTravel(Map<String, String>
dynamicOptions) {
+ return new ChainTableFileStoreTable(
+ wrapped.copyWithoutTimeTravel(dynamicOptions),
+
other().copyWithoutTimeTravel(rewriteOtherOptions(dynamicOptions)));
+ }
+
+ @Override
+ public FileStoreTable copyWithLatestSchema() {
+ return new ChainTableFileStoreTable(
+ wrapped.copyWithLatestSchema(),
other().copyWithLatestSchema());
+ }
+
+ @Override
+ public FileStoreTable switchToBranch(String branchName) {
+ return new ChainTableFileStoreTable(switchWrappedToBranch(branchName),
other());
+ }
+
+ /**
+ * Chain-aware read implementation that pairs with {@link
ChainTableStreamScan}. Routes splits
+ * based on type:
+ *
+ * <ul>
+ * <li><b>ChainSplit / DataSplit:</b> Streaming read splits. Routed to
{@link
+ * ChainGroupReadTable}'s read which uses {@link
+ * org.apache.paimon.io.ChainKeyValueFileReaderFactory} (both branch
schemas for
+ * ChainSplit) or delta branch read (DataSplit with correct schema).
+ * <li><b>FallbackSplit:</b> Batch read fallback splits. Routed to
inherited {@link
+ * FallbackReadFileStoreTable} read for partition-level fallback
logic.
+ * </ul>
+ */
+ private class ChainTableRead implements InnerTableRead {
+
+ private final InnerTableRead chainGroupRead;
+ private final InnerTableRead fallbackRead;
+
+ private ChainTableRead() {
+ this.chainGroupRead = other().newRead();
+ this.fallbackRead = ChainTableFileStoreTable.super.newRead();
+ }
+
+ @Override
+ public InnerTableRead withFilter(Predicate predicate) {
+ chainGroupRead.withFilter(predicate);
+ fallbackRead.withFilter(predicate);
+ return this;
+ }
+
+ @Override
+ public InnerTableRead withReadType(RowType readType) {
+ chainGroupRead.withReadType(readType);
+ fallbackRead.withReadType(readType);
+ return this;
+ }
+
+ @Override
+ public InnerTableRead forceKeepDelete() {
+ chainGroupRead.forceKeepDelete();
+ fallbackRead.forceKeepDelete();
+ return this;
+ }
+
+ @Override
+ public TableRead executeFilter() {
+ chainGroupRead.executeFilter();
+ fallbackRead.executeFilter();
+ return this;
+ }
+
+ @Override
+ public TableRead withIOManager(IOManager ioManager) {
+ chainGroupRead.withIOManager(ioManager);
+ fallbackRead.withIOManager(ioManager);
+ return this;
+ }
+
+ @Override
+ public RecordReader<InternalRow> createReader(Split split) throws
IOException {
+ if (split instanceof FallbackSplit) {
+ // FallbackSplit (including FallbackDataSplit): use inherited
fallback read logic
+ return fallbackRead.createReader(split);
+ }
+ if (split instanceof ChainSplit || split instanceof DataSplit) {
+ return chainGroupRead.createReader(split);
+ }
+ // Other split types: use inherited fallback read logic
+ return fallbackRead.createReader(split);
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/ChainTableStreamScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/ChainTableStreamScan.java
new file mode 100644
index 0000000000..276114712a
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/table/ChainTableStreamScan.java
@@ -0,0 +1,494 @@
+/*
+ * 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.table;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.codegen.CodeGenUtils;
+import org.apache.paimon.codegen.RecordComparator;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.manifest.PartitionEntry;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateVisitor;
+import org.apache.paimon.table.source.ChainSplit;
+import org.apache.paimon.table.source.DataFilePlan;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.DataTableScan;
+import org.apache.paimon.table.source.DataTableStreamScan;
+import org.apache.paimon.table.source.InnerTableScan;
+import org.apache.paimon.table.source.SnapshotNotExistPlan;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.StreamDataTableScan;
+import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.table.source.snapshot.StartingContext;
+import org.apache.paimon.utils.ChainPartitionProjector;
+import org.apache.paimon.utils.ChainTableUtils;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Streaming scan for chain tables with a two-phase design:
+ *
+ * <ul>
+ * <li><b>Phase 1 (Starting):</b> Outputs the latest snapshot partition (per
group) and delta
+ * partitions that come after it. Older snapshot partitions are excluded
as they are
+ * considered outdated. Each primary key appears exactly once under its
natural partition.
+ * Unlike batch full scan, anchor-based chain merging is intentionally
skipped to keep Phase 1
+ * lightweight — this avoids split explosion in long-running jobs with
many partitions.
+ * <li><b>Phase 2 (Incremental):</b> Stream new snapshots from the delta
branch only, picking up
+ * from where Phase 1 left off.
+ * </ul>
+ *
+ * <p>Checkpoint state is a single {@code Long} — the delta branch's next
snapshot id. On stateful
+ * restart, Phase 1 is skipped and incremental streaming resumes from the
checkpointed position. On
+ * stateless restart (null state), a fresh starting scan is performed.
+ */
+public class ChainTableStreamScan implements StreamDataTableScan {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(ChainTableStreamScan.class);
+
+ private final ChainGroupReadTable chainGroupReadTable;
+
+ /** Phase 1: batch scan used to access snapshot branch data via {@code
mainScan}. */
+ private final ChainGroupReadTable.ChainTableBatchScan batchScan;
+
+ /** Phase 2: delta-only stream scan. */
+ private final DataTableStreamScan deltaStreamScan;
+
+ /** Projector for splitting full partition into group and chain parts. */
+ private final ChainPartitionProjector partitionProjector;
+
+ /** Comparator for chain partition keys only. */
+ private final RecordComparator chainPartitionComparator;
+
+ /** Partition keys of the table, used to reject partition filters in
streaming mode. */
+ private final List<String> partitionKeys;
+
+ /**
+ * Checkpoint state: the next delta snapshot id to read. Null before Phase
1 completes; non-null
+ * once Phase 1 is done or after a stateful restore.
+ */
+ @Nullable private Long nextDeltaSnapshotId;
+
+ /** Whether the starting plan (Phase 1) has been completed. */
+ private boolean startingDone = false;
+
+ /** Predicates and shard for applying to local scans created in {@link
#planStarting()}. */
+ private final List<Predicate> predicates = new ArrayList<>();
+
+ private int shardIndex = -1;
+
+ private int shardCount = -1;
+
+ /** Maximum number of retries when race condition is detected during
position capture. */
+ private static final int MAX_RACE_RETRIES = 3;
+
+ public ChainTableStreamScan(ChainGroupReadTable chainGroupReadTable) {
+ this.chainGroupReadTable = chainGroupReadTable;
+ this.batchScan =
+ new ChainGroupReadTable.ChainTableBatchScan(
+ chainGroupReadTable.schema(), chainGroupReadTable);
+ this.deltaStreamScan = (DataTableStreamScan)
chainGroupReadTable.other().newStreamScan();
+
+ // Initialize partition projector and chain comparator using the
established pattern
+ // from ChainTableBatchScan.
+ List<String> chainKeys =
+ ChainTableUtils.chainPartitionKeys(
+ chainGroupReadTable.coreOptions(),
+ chainGroupReadTable.schema().partitionKeys());
+ this.partitionProjector =
+ new ChainPartitionProjector(
+ chainGroupReadTable.schema().logicalPartitionType(),
chainKeys.size());
+ this.chainPartitionComparator =
+ CodeGenUtils.newRecordComparator(
+
partitionProjector.chainPartitionType().getFieldTypes());
+ this.partitionKeys = chainGroupReadTable.schema().partitionKeys();
+ }
+
+ @Override
+ public StartingContext startingContext() {
+ if (!startingDone) {
+ return StartingContext.EMPTY;
+ }
+ return deltaStreamScan.startingContext();
+ }
+
+ @Override
+ public TableScan.Plan plan() {
+ if (!startingDone) {
+ return planStarting();
+ }
+
+ TableScan.Plan plan = deltaStreamScan.plan();
+ // Never return SnapshotNotExistPlan — it would cause the Flink
enumerator to
+ // set stopTriggerScan=true and permanently stop polling for new data.
+ if (plan instanceof SnapshotNotExistPlan) {
+ return new DataFilePlan<>(Collections.emptyList());
+ }
+
+ // Phase 2 reads from the delta branch only. The delta stream scan
already produces
+ // DataSplits with the correct branch context and all required
metadata (bucket,
+ // isStreaming, rawConvertible, deletionFiles). Wrapping them in
ChainSplit would drop
+ // that metadata, which breaks changelog-producer=input streaming
reads because the
+ // reader would fall back to LSM merging instead of streaming the
changelog rows.
+ return plan;
+ }
+
+ /**
+ * Starting plan: outputs the latest snapshot partition (per group) and
delta partitions that
+ * come after it. Older snapshot partitions are excluded. Each primary key
appears exactly once
+ * under its natural partition.
+ *
+ * <p>Unlike batch full scan, anchor-based chain merging is not performed.
This keeps Phase 1
+ * lightweight for long-running jobs.
+ */
+ private TableScan.Plan planStarting() {
+ FileStoreTable deltaTable = chainGroupReadTable.other();
+ String deltaBranch = deltaTable.coreOptions().branch();
+ String snapshotBranch =
chainGroupReadTable.wrapped.coreOptions().branch();
+
+ // Capture both delta and snapshot positions with race detection.
+ // We capture snapshot, delta, snapshot again. If the first and third
snapshot IDs
+ // differ, a race occurred and we retry. This prevents data loss from
snapshot commits
+ // between captures.
+ Long snapshotLatestId;
+ Long deltaLatestId;
+ int attempt = 0;
+ while (true) {
+ Long snapshotId1 =
chainGroupReadTable.wrapped.snapshotManager().latestSnapshotId();
+ deltaLatestId = captureDeltaPosition(deltaTable);
+ Long snapshotId2 =
chainGroupReadTable.wrapped.snapshotManager().latestSnapshotId();
+
+ if (Objects.equals(snapshotId1, snapshotId2)) {
+ // No race detected
+ snapshotLatestId = snapshotId1;
+ LOG.info(
+ "ChainTableStreamScan: captured positions (attempt
{}): "
+ + "snapshot={}, delta={}",
+ attempt + 1,
+ snapshotId1,
+ deltaLatestId);
+ break;
+ }
+
+ // Race detected: snapshot committed between captures
+ LOG.warn(
+ "ChainTableStreamScan: race condition detected (attempt
{}): "
+ + "snapshot changed from {} to {}",
+ attempt + 1,
+ snapshotId1,
+ snapshotId2);
+
+ attempt++;
+ if (attempt >= MAX_RACE_RETRIES) {
+ throw new IllegalStateException(
+ "ChainTableStreamScan: failed to capture consistent
positions after "
+ + MAX_RACE_RETRIES
+ + " retries due to continuous snapshot
commits. "
+ + "This indicates high snapshot commit
frequency. "
+ + "The job will fail and rely on Flink
failover mechanism to retry.");
+ }
+ }
+
+ // 1. Read delta branch data at the pinned snapshot, grouped by
partition.
+ Map<BinaryRow, List<DataSplit>> deltaSplitsByPartition;
+ if (deltaLatestId != null) {
+ FileStoreTable pinnedDelta =
+ deltaTable.copy(
+ Collections.singletonMap(
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ String.valueOf(deltaLatestId)));
+ DataTableScan pinnedDeltaScan = pinnedDelta.newScan();
+ applyPredicatesAndShard(pinnedDeltaScan);
+ deltaSplitsByPartition = groupByPartition(pinnedDeltaScan);
+ } else {
+ deltaSplitsByPartition = Collections.emptyMap();
+ }
+
+ // 2. List snapshot partitions at the pinned snapshot (lightweight —
partition metadata
+ // only, no file I/O). Find the latest chain partition per group,
then scan only those
+ // partitions for files. This avoids reading file manifests for
hundreds of historical
+ // partitions that will be discarded (only the latest per group is
kept).
+ Map<Object, BinaryRow> latestChainPartitionPerGroup = new HashMap<>();
+ FileStoreTable pinnedSnapshot = null;
+ if (snapshotLatestId != null) {
+ pinnedSnapshot =
+ chainGroupReadTable.wrapped.copy(
+ Collections.singletonMap(
+ CoreOptions.SCAN_SNAPSHOT_ID.key(),
+ String.valueOf(snapshotLatestId)));
+ DataTableScan partitionListingScan = pinnedSnapshot.newScan();
+ for (BinaryRow partition : partitionListingScan.listPartitions()) {
+ Object groupKey = toGroupKey(partition);
+ BinaryRow existingLatest =
latestChainPartitionPerGroup.get(groupKey);
+ if (existingLatest == null
+ || chainPartitionComparator.compare(
+
partitionProjector.extractChainPartition(partition),
+
partitionProjector.extractChainPartition(existingLatest))
+ > 0) {
+ latestChainPartitionPerGroup.put(groupKey, partition);
+ }
+ }
+ }
+
+ // 3. Scan file splits for latest snapshot partitions only, at the
pinned snapshot.
+ // Reuse the pinnedSnapshot from step 2 to avoid redundant copy
operations.
+ List<BinaryRow> latestPartitions = new
ArrayList<>(latestChainPartitionPerGroup.values());
+ Map<BinaryRow, List<DataSplit>> snapshotSplitsByPartition;
+ if (!latestPartitions.isEmpty() && pinnedSnapshot != null) {
+ DataTableScan snapshotScan = pinnedSnapshot.newScan();
+ snapshotScan.withPartitionFilter(latestPartitions);
+ applyPredicatesAndShard(snapshotScan);
+ snapshotSplitsByPartition = groupByPartition(snapshotScan);
+ } else {
+ snapshotSplitsByPartition = Collections.emptyMap();
+ }
+
+ // 4. Build ChainSplits:
+ // - Snapshot partitions are already filtered to latest per group
at the pinned snapshot.
+ // - Delta partitions: include partitions with chain key > latest
snapshot chain key for
+ // that group, or all partitions if no snapshot exists for that
group.
+ List<Split> allSplits = new ArrayList<>();
+
+ for (Map.Entry<BinaryRow, List<DataSplit>> entry :
snapshotSplitsByPartition.entrySet()) {
+ for (DataSplit ds : entry.getValue()) {
+ allSplits.add(ChainSplit.from(ds, snapshotBranch));
+ }
+ }
+
+ for (Map.Entry<BinaryRow, List<DataSplit>> entry :
deltaSplitsByPartition.entrySet()) {
+ BinaryRow partition = entry.getKey();
+ Object groupKey = toGroupKey(partition);
+ BinaryRow latestPartition =
latestChainPartitionPerGroup.get(groupKey);
+ // Include delta partition if:
+ // - No snapshot exists for this group, OR
+ // - Chain key > latest snapshot chain key
+ if (latestPartition == null
+ || chainPartitionComparator.compare(
+
partitionProjector.extractChainPartition(partition),
+
partitionProjector.extractChainPartition(latestPartition))
+ > 0) {
+ for (DataSplit ds : entry.getValue()) {
+ allSplits.add(ChainSplit.from(ds, deltaBranch));
+ }
+ }
+ }
+
+ LOG.info(
+ "ChainTableStreamScan.planStarting [snapshot={}, delta={}]: "
+ + "{} delta partitions, {} snapshot partitions, "
+ + "{} latest snapshot groups, {} total splits",
+ snapshotBranch,
+ deltaBranch,
+ deltaSplitsByPartition.size(),
+ snapshotSplitsByPartition.size(),
+ latestChainPartitionPerGroup.size(),
+ allSplits.size());
+
+ startingDone = true;
+ return new DataFilePlan<>(allSplits);
+ }
+
+ /**
+ * Captures the delta branch's latest snapshot id and positions the Phase
2 stream scan to start
+ * from the next snapshot. This makes the Phase 1 / Phase 2 boundary
deterministic: Phase 1
+ * reads delta data pinned at the returned snapshot id, Phase 2 starts
from the snapshot after.
+ *
+ * @return the latest delta snapshot id, or {@code null} if the delta
branch has no snapshots
+ */
+ @Nullable
+ private Long captureDeltaPosition(FileStoreTable deltaTable) {
+ SnapshotManager deltaSnapshotManager = deltaTable.snapshotManager();
+ Long latestId = deltaSnapshotManager.latestSnapshotId();
+ nextDeltaSnapshotId = latestId != null ? latestId + 1 :
Snapshot.FIRST_SNAPSHOT_ID;
+ LOG.info(
+ "ChainTableStreamScan: pinned delta branch '{}' at snapshot
{}, "
+ + "nextDeltaSnapshotId={}",
+ deltaTable.coreOptions().branch(),
+ latestId,
+ nextDeltaSnapshotId);
+ deltaStreamScan.restore(nextDeltaSnapshotId);
+ return latestId;
+ }
+
+ /** Plans a scan and groups the resulting splits by partition. */
+ private static Map<BinaryRow, List<DataSplit>>
groupByPartition(DataTableScan scan) {
+ Map<BinaryRow, List<DataSplit>> grouped = new LinkedHashMap<>();
+ for (Split s : scan.plan().splits()) {
+ DataSplit ds = (DataSplit) s;
+ grouped.computeIfAbsent(ds.partition(), k -> new
ArrayList<>()).add(ds);
+ }
+ return grouped;
+ }
+
+ /**
+ * Extracts a stable group key from a full partition row. When there is no
group partition (all
+ * fields are chain keys), returns a shared singleton to avoid zero-field
{@link BinaryRow}
+ * instances that may have inconsistent {@code hashCode}/{@code equals}
across different
+ * partitions.
+ */
+ private Object toGroupKey(BinaryRow fullPartition) {
+ if (!partitionProjector.hasGroupPartition()) {
+ return Collections.emptyList();
+ }
+ return partitionProjector.extractGroupPartition(fullPartition);
+ }
+
+ @Override
+ public InnerTableScan withFilter(Predicate predicate) {
+ if (predicate == null) {
+ return this;
+ }
+ if (!partitionKeys.isEmpty()) {
+ Set<String> referencedFields =
PredicateVisitor.collectFieldNames(predicate);
+ boolean containsPartitionField =
+
referencedFields.stream().anyMatch(partitionKeys::contains);
+ if (containsPartitionField) {
+ throw new UnsupportedOperationException(
+ "Partition filter is not supported in chain table
streaming read. "
+ + "The chain table streaming scan determines
which partitions to read "
+ + "based on the chain-merge logic across
snapshot and delta branches. "
+ + "Applying a partition filter would interfere
with this logic. "
+ + "If you need to read a specific partition,
use batch mode instead.");
+ }
+ }
+ predicates.add(predicate);
+ batchScan.withFilter(predicate);
+ deltaStreamScan.withFilter(predicate);
+ return this;
+ }
+
+ @Override
+ public InnerTableScan withPartitionFilter(Map<String, String>
partitionSpec) {
+ throw new UnsupportedOperationException(
+ "Partition filter is not supported in chain table streaming
read.");
+ }
+
+ @Override
+ public InnerTableScan withPartitionFilter(List<BinaryRow> partitions) {
+ throw new UnsupportedOperationException(
+ "Partition filter is not supported in chain table streaming
read.");
+ }
+
+ @Override
+ public InnerTableScan withPartitionFilter(PartitionPredicate
partitionPredicate) {
+ if (partitionPredicate != null) {
+ throw new UnsupportedOperationException(
+ "Partition filter is not supported in chain table
streaming read.");
+ }
+ return this;
+ }
+
+ @Override
+ public InnerTableScan withPartitionFilter(Predicate predicate) {
+ if (predicate != null) {
+ throw new UnsupportedOperationException(
+ "Partition filter is not supported in chain table
streaming read.");
+ }
+ return this;
+ }
+
+ @Override
+ public DataTableScan withShard(int indexOfThisSubtask, int
numberOfParallelSubtasks) {
+ shardIndex = indexOfThisSubtask;
+ shardCount = numberOfParallelSubtasks;
+ batchScan.withShard(indexOfThisSubtask, numberOfParallelSubtasks);
+ deltaStreamScan.withShard(indexOfThisSubtask,
numberOfParallelSubtasks);
+ return this;
+ }
+
+ /**
+ * Applies all previously set predicates and shard to a newly created
scan. Used for the pinned
+ * delta scan in {@link #planStarting()}.
+ */
+ private void applyPredicatesAndShard(DataTableScan scan) {
+ for (Predicate p : predicates) {
+ scan.withFilter(p);
+ }
+ if (shardIndex >= 0) {
+ scan.withShard(shardIndex, shardCount);
+ }
+ }
+
+ @Nullable
+ @Override
+ public Long checkpoint() {
+ if (startingDone) {
+ return deltaStreamScan.checkpoint();
+ }
+ return nextDeltaSnapshotId;
+ }
+
+ @Nullable
+ @Override
+ public Long watermark() {
+ if (!startingDone) {
+ return null;
+ }
+ return deltaStreamScan.watermark();
+ }
+
+ @Override
+ public void restore(@Nullable Long nextSnapshotId) {
+ this.nextDeltaSnapshotId = nextSnapshotId;
+ if (nextSnapshotId != null) {
+ startingDone = true;
+ deltaStreamScan.restore(nextSnapshotId);
+ } else {
+ startingDone = false;
+ }
+ }
+
+ @Override
+ public void restore(@Nullable Long nextSnapshotId, boolean
scanAllSnapshot) {
+ if (scanAllSnapshot) {
+ startingDone = false;
+ this.nextDeltaSnapshotId = nextSnapshotId;
+ // No need to call deltaStreamScan.restore() here — Phase 1 will
re-run and
+ // captureDeltaPosition() will re-position the delta stream scan.
+ } else {
+ restore(nextSnapshotId);
+ }
+ }
+
+ @Override
+ public void notifyCheckpointComplete(@Nullable Long nextSnapshot) {
+ deltaStreamScan.notifyCheckpointComplete(nextSnapshot);
+ }
+
+ @Override
+ public List<PartitionEntry> listPartitionEntries() {
+ throw new UnsupportedOperationException(
+ "List Partition Entries is not supported in Chain Table Stream
Scan.");
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/FileStoreTableFactory.java
b/paimon-core/src/main/java/org/apache/paimon/table/FileStoreTableFactory.java
index e4a8656799..53be2803ce 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/FileStoreTableFactory.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/FileStoreTableFactory.java
@@ -181,7 +181,7 @@ public class FileStoreTableFactory {
catalogEnvironment);
FileStoreTable chainGroupFileStoreTable =
new ChainGroupReadTable(snapshotTable, deltaTable);
- return new FallbackReadFileStoreTable(table, chainGroupFileStoreTable,
true);
+ return new ChainTableFileStoreTable(table, chainGroupFileStoreTable);
}
private static FileStoreTable createOtherBranchTable(
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
index dfa364f96a..6b3512c615 100644
--- a/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
+++ b/paimon-core/src/main/java/org/apache/paimon/table/source/ChainSplit.java
@@ -79,6 +79,24 @@ public class ChainSplit implements Split {
return fileBucketPathMapping;
}
+ /**
+ * Creates a {@link ChainSplit} from a {@link DataSplit} where all data
files belong to the same
+ * branch.
+ */
+ public static ChainSplit from(DataSplit dataSplit, String branch) {
+ HashMap<String, String> fileBranchMapping = new HashMap<>();
+ HashMap<String, String> fileBucketPathMapping = new HashMap<>();
+ for (DataFileMeta file : dataSplit.dataFiles()) {
+ fileBranchMapping.put(file.fileName(), branch);
+ fileBucketPathMapping.put(file.fileName(), dataSplit.bucketPath());
+ }
+ return new ChainSplit(
+ dataSplit.partition(),
+ dataSplit.dataFiles(),
+ fileBranchMapping,
+ fileBucketPathMapping);
+ }
+
@Override
public long rowCount() {
long sum = 0;
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/ContinuousFileSplitEnumerator.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/ContinuousFileSplitEnumerator.java
index 77aeeb8508..a8c77aab7f 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/ContinuousFileSplitEnumerator.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/ContinuousFileSplitEnumerator.java
@@ -25,6 +25,7 @@ import org.apache.paimon.flink.source.assigners.SplitAssigner;
import org.apache.paimon.postpone.PostponeBucketFileStoreWrite;
import org.apache.paimon.table.BucketMode;
import org.apache.paimon.table.sink.ChannelComputer;
+import org.apache.paimon.table.source.ChainSplit;
import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.table.source.EndOfScanException;
import org.apache.paimon.table.source.IncrementalSplit;
@@ -328,6 +329,8 @@ public class ContinuousFileSplitEnumerator
protected int assignSuggestedTask(FileStoreSourceSplit split) {
if (split.split() instanceof DataSplit) {
return assignSuggestedTask((DataSplit) split.split());
+ } else if (split.split() instanceof ChainSplit) {
+ return assignSuggestedTask((ChainSplit) split.split());
} else {
return assignSuggestedTask((IncrementalSplit) split.split());
}
@@ -364,6 +367,35 @@ public class ContinuousFileSplitEnumerator
}
}
+ protected int assignSuggestedTask(ChainSplit split) {
+ int parallelism = context.currentParallelism();
+ // Extract bucket id from the bucket path stored in
fileBucketPathMapping.
+ // The bucket path ends with "bucket-{id}".
+ int bucketId = 0;
+ if (!split.fileBucketPathMapping().isEmpty()) {
+ String bucketPath =
split.fileBucketPathMapping().values().iterator().next();
+ int lastSlash = bucketPath.lastIndexOf('/');
+ if (lastSlash >= 0) {
+ String bucketDir = bucketPath.substring(lastSlash + 1);
+ if (bucketDir.startsWith("bucket-")) {
+ try {
+ bucketId =
Integer.parseInt(bucketDir.substring("bucket-".length()));
+ } catch (NumberFormatException e) {
+ LOG.warn(
+ "Failed to parse bucket id from path '{}',
falling back to 0.",
+ bucketPath,
+ e);
+ }
+ }
+ }
+ }
+ if (shuffleBucketWithPartition) {
+ return ChannelComputer.select(split.logicalPartition(), bucketId,
parallelism);
+ } else {
+ return ChannelComputer.select(bucketId, parallelism);
+ }
+ }
+
protected SplitAssigner createSplitAssigner(boolean unordered) {
return unordered
? new FIFOSplitAssigner(Collections.emptyList())
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 3e96dec1ea..2e9c8ae494 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
@@ -326,6 +326,12 @@ public class FlinkSourceBuilder {
TableScanUtils.streamingReadingValidate(table);
if (conf.get(FlinkConnectorOptions.SOURCE_CHECKPOINT_ALIGN_ENABLED)) {
+ if (conf.get(CoreOptions.CHAIN_TABLE_ENABLED)) {
+ throw new UnsupportedOperationException(
+ "Chain table streaming is not compatible with
checkpoint-align mode. "
+ + "Please disable
'source.checkpoint-align.enabled' when reading "
+ + "a chain table in streaming mode.");
+ }
return buildAlignedContinuousFileSource();
} else if (conf.contains(CoreOptions.CONSUMER_ID)
&& conf.get(CoreOptions.CONSUMER_CONSISTENCY_MODE)
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkChainTableITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkChainTableITCase.java
index 8ac39d1430..7d7a053a16 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkChainTableITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkChainTableITCase.java
@@ -18,14 +18,43 @@
package org.apache.paimon.flink;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.flink.sink.FlinkSinkBuilder;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.table.ChainTableStreamScan;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataTableScan;
+import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.utils.BlockingIterator;
+
+import org.apache.flink.configuration.CheckpointingOptions;
+import org.apache.flink.configuration.ExternalizedCheckpointRetention;
+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.DataTypes;
+import org.apache.flink.table.api.TableResult;
import org.apache.flink.types.Row;
+import org.apache.flink.types.RowKind;
import org.apache.flink.util.CloseableIterator;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** IT cases for chain table using Flink SQL. */
public class FlinkChainTableITCase extends CatalogITCaseBase {
@@ -688,4 +717,1674 @@ public class FlinkChainTableITCase extends
CatalogITCaseBase {
.containsExactlyInAnyOrder(
"+I[2, 2, 1-1, CN, 20250811]", "+I[4, 1, 1, CN,
20250811]");
}
+
+ /** Write Row data (with RowKind) to a specific branch using DataStream
API. */
+ private void writeChangelogToBranch(String db, String tableName, String
branch, Row... rows)
+ throws Exception {
+ FileStoreTable table = paimonTable(tableName + "$branch_" + branch);
+
+ StreamExecutionEnvironment env =
+ streamExecutionEnvironmentBuilder()
+ .streamingMode()
+ .checkpointIntervalMs(100)
+ .parallelism(1)
+ .build();
+
+ DataStream<Row> stream = env.fromCollection(Arrays.asList(rows));
+
+ new FlinkSinkBuilder(table)
+ .forRow(
+ stream,
+ DataTypes.ROW(
+ DataTypes.FIELD("k", DataTypes.BIGINT()),
+ DataTypes.FIELD("seq", DataTypes.BIGINT()),
+ DataTypes.FIELD("v", DataTypes.STRING()),
+ DataTypes.FIELD("dt", DataTypes.STRING())))
+ .build();
+ env.execute();
+ }
+
+ /**
+ * Collect n rows from a streaming iterator with a timeout. If no data
arrives within
+ * timeoutSeconds, the iterator is closed and an AssertionError is thrown.
This is necessary
+ * because it.next() blocks indefinitely when no data is available, and
JUnit @Timeout cannot
+ * interrupt it.
+ */
+ /**
+ * Collects {@code n} rows from a streaming iterator using the
project-standard {@link
+ * BlockingIterator}.
+ */
+ private List<String> collectRows(CloseableIterator<Row> it, int n) throws
Exception {
+ return BlockingIterator.of(it).collect(n, 30,
TimeUnit.SECONDS).stream()
+ .map(Row::toString)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Polls the given table until it contains at least {@code minRows} rows.
Used instead of
+ * fixed-duration Thread.sleep to avoid flaky tests on slow CI.
+ */
+ private void waitForRowCount(String tableName, int minRows) throws
Exception {
+ long deadline = System.currentTimeMillis() + 60_000;
+ int count = 0;
+ while (System.currentTimeMillis() < deadline) {
+ List<Row> rows = sql("SELECT * FROM " + tableName);
+ count = rows.size();
+ if (count >= minRows) {
+ return;
+ }
+ Thread.sleep(1000);
+ }
+ throw new AssertionError(
+ "Timed out waiting for " + minRows + " rows in " + tableName +
", got " + count);
+ }
+
+ /**
+ * Tests the streaming read lifecycle for a chain table with
changelog-producer=input.
+ *
+ * <p>Verifies: initial full read from delta-only → delta incremental
visible with changelog
+ * records (-U/+U) → snapshot OVERWRITE has no effect → more delta visible
→ stateless restart
+ * reads chain-merged state.
+ */
+ @Test
+ @Timeout(120)
+ public void testStreamingReadChainTableLifecycleWithInputChangelog()
throws Exception {
+ // Create chain table with changelog-producer=input
+ sql(
+ "CREATE TABLE chain_life_cl ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_life_cl', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_life_cl', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_life_cl", "chain_life_cl$branch_snapshot",
"chain_life_cl$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // === Phase 1: Delta-only initial data (all inserts) ===
+ sql(
+ "INSERT INTO `chain_life_cl$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'base_1'), (2, 1, 'base_2'), (3, 1,
'base_3'),"
+ + " (4, 1, 'base_4'), (5, 1, 'base_5')");
+
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_life_cl").collect();
+
+ List<String> phase1 = collectRows(it, 5);
+ assertThat(phase1)
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, base_1, 20250808]",
+ "+I[2, 1, base_2, 20250808]",
+ "+I[3, 1, base_3, 20250808]",
+ "+I[4, 1, base_4, 20250808]",
+ "+I[5, 1, base_5, 20250808]");
+
+ // === Phase 2: Write changelog data (with -U/+U for update) via
DataStream API ===
+ writeChangelogToBranch(
+ db,
+ "chain_life_cl",
+ "delta",
+ Row.ofKind(RowKind.UPDATE_BEFORE, 3L, 1L, "base_3",
"20250809"),
+ Row.ofKind(RowKind.UPDATE_AFTER, 3L, 2L, "upd_3", "20250809"),
+ Row.ofKind(RowKind.INSERT, 6L, 1L, "new_6", "20250809"),
+ Row.ofKind(RowKind.INSERT, 7L, 1L, "new_7", "20250809"));
+
+ List<String> phase2 = collectRows(it, 4);
+ // changelog-producer=input: explicit -U/+U for updates
+ assertThat(phase2)
+ .containsExactlyInAnyOrder(
+ "-U[3, 1, base_3, 20250809]",
+ "+U[3, 2, upd_3, 20250809]",
+ "+I[6, 1, new_6, 20250809]",
+ "+I[7, 1, new_7, 20250809]");
+
+ // === Phase 3: Snapshot OVERWRITE should have NO effect ===
+ sql(
+ "INSERT OVERWRITE `chain_life_cl$branch_snapshot` PARTITION
(dt = '20250808')"
+ + " VALUES (1, 1, 'base_1'), (2, 1, 'base_2'), (3, 1,
'base_3'),"
+ + " (4, 1, 'base_4'), (5, 1, 'base_5')");
+
+ // Write delta AFTER snapshot — this proves snapshot writes don't
trigger output.
+ // If snapshot writes were detected, we'd see duplicate or unexpected
rows.
+ writeChangelogToBranch(
+ db,
+ "chain_life_cl",
+ "delta",
+ Row.ofKind(RowKind.INSERT, 100L, 1L, "phase3_probe",
"20250810"));
+
+ List<String> phase3 = collectRows(it, 1);
+ assertThat(phase3)
+ .as("Only delta write should produce output, snapshot
OVERWRITE should be ignored")
+ .containsExactlyInAnyOrder("+I[100, 1, phase3_probe,
20250810]");
+
+ // === Phase 4: Write more delta via DataStream API ===
+ writeChangelogToBranch(
+ db,
+ "chain_life_cl",
+ "delta",
+ Row.ofKind(RowKind.INSERT, 8L, 1L, "new_8", "20250810"),
+ Row.ofKind(RowKind.INSERT, 9L, 1L, "new_9", "20250810"));
+
+ List<String> phase4 = collectRows(it, 2);
+ assertThat(phase4)
+ .containsExactlyInAnyOrder(
+ "+I[8, 1, new_8, 20250810]", "+I[9, 1, new_9,
20250810]");
+
+ // Terminate first streaming job
+ it.close();
+
+ // === Phase 5: Stateless restart ===
+ CloseableIterator<Row> it2 = sEnv.executeSql("SELECT * FROM
chain_life_cl").collect();
+
+ // Phase 5 starting (matching batch semantics):
+ // - snapshot@20250808: k=1-5 (snapshot wins, delta@20250808 skipped
since same partition
+ // exists in snapshot; same values here since OVERWRITE wrote
identical base data)
+ // - delta@20250809: changelog records (+U for update, +I for inserts)
+ // - delta@20250810: k=8,9,100 (delta-only, no snapshot for this
partition)
+ // Total: 11 unique rows (PK=(dt,k) makes each (dt,k) pair distinct).
+ List<String> restart = collectRows(it2, 11);
+ assertThat(restart)
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, base_1, 20250808]",
+ "+I[2, 1, base_2, 20250808]",
+ "+I[3, 1, base_3, 20250808]",
+ "+U[3, 2, upd_3, 20250809]",
+ "+I[4, 1, base_4, 20250808]",
+ "+I[5, 1, base_5, 20250808]",
+ "+I[6, 1, new_6, 20250809]",
+ "+I[7, 1, new_7, 20250809]",
+ "+I[8, 1, new_8, 20250810]",
+ "+I[9, 1, new_9, 20250810]",
+ "+I[100, 1, phase3_probe, 20250810]");
+
+ // Continue writing delta
+ writeChangelogToBranch(
+ db,
+ "chain_life_cl",
+ "delta",
+ Row.ofKind(RowKind.INSERT, 10L, 1L, "new_10", "20250811"),
+ Row.ofKind(RowKind.INSERT, 11L, 1L, "new_11", "20250811"));
+
+ List<String> phase5b = collectRows(it2, 2);
+ assertThat(phase5b)
+ .containsExactlyInAnyOrder(
+ "+I[10, 1, new_10, 20250811]", "+I[11, 1, new_11,
20250811]");
+
+ it2.close();
+ }
+
+ /**
+ * Tests stateful restart of a chain table streaming read job using Flink
checkpoint/restore.
+ *
+ * <p>Phase 1: Write initial delta data, start streaming job. Phase 2:
Write incremental delta,
+ * let Phase 2 consume it, then checkpoint and cancel. Phase 3: Write new
delta data while the
+ * job is down. Phase 4: Restart from checkpoint — the restored scan must
NOT re-read the
+ * already-consumed delta (verifies checkpoint() returns the advanced
cursor, not the stale
+ * Phase 1 boundary). Phase 5: Verify incremental streaming continues
after restore.
+ */
+ @Test
+ @Timeout(180)
+ public void testStreamingReadChainTableStatefulRestart() throws Exception {
+ // Create chain table (source)
+ sql(
+ "CREATE TABLE chain_restart ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ // Create a Paimon PK sink table. The Paimon sink supports upsert
+ // (primary key), so the planner won't need ChangelogNormalize.
+ // Paimon sink does NOT implement CheckpointedFunction (it uses
operator
+ // state for in-flight files, committed during checkpoint complete),
so no
+ // buffer leakage on checkpoint recovery — unlike CollectSinkFunction.
+ sql(
+ "CREATE TABLE chain_restart_sink ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING,"
+ + " PRIMARY KEY (dt, k) NOT ENFORCED"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'bucket' = '2',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'sequence.field' = 'seq'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_restart', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_restart', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_restart", "chain_restart$branch_snapshot",
"chain_restart$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Configure checkpoint for stateful restart
+ org.apache.flink.configuration.Configuration config =
sEnv.getConfig().getConfiguration();
+ config.setString("state.checkpoints.dir", "file://" + path +
"/checkpoints");
+ config.set(
+ CheckpointingOptions.EXTERNALIZED_CHECKPOINT_RETENTION,
+ ExternalizedCheckpointRetention.RETAIN_ON_CANCELLATION);
+ // Enable auto-checkpointing (1s) so Phase 2 data is committed before
we take the
+ // savepoint. This ensures the enumerator's delta cursor has advanced
past
+ // delta@20250809, which is the scenario the checkpoint() regression
would break.
+ config.setString("execution.checkpointing.interval", "1000");
+
+ // Same SQL for both phases → operator graph matches → state recovery
works
+ String streamSql = "INSERT INTO chain_restart_sink SELECT * FROM
chain_restart";
+
+ // T4: Write snapshot data BEFORE starting streaming, so the starting
phase
+ // exercises the snapshot+delta merge path (not just delta-only).
+ sql(
+ "INSERT INTO `chain_restart$branch_snapshot` PARTITION (dt =
'20250807')"
+ + " VALUES (10, 1, 'snap_10'), (11, 1, 'snap_11')");
+
+ // === Phase 1: Write initial delta data and start streaming INSERT
INTO ===
+ sql(
+ "INSERT INTO `chain_restart$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'base_1'), (2, 1, 'base_2'), (3, 1,
'base_3')");
+
+ TableResult tableResult = sEnv.executeSql(streamSql);
+ //noinspection OptionalGetWithoutIsPresent
+ JobClient jobClient = tableResult.getJobClient().get();
+
+ // === Phase 2: Write incremental delta, let Phase 2 consume it, THEN
checkpoint ===
+ // This exercises the checkpoint() regression: if checkpoint() returns
the stale
+ // Phase 1 boundary instead of the advanced delta cursor, restore
would re-read
+ // delta@20250809 and produce duplicates.
+ sql(
+ "INSERT INTO `chain_restart$branch_delta` PARTITION (dt =
'20250809')"
+ + " VALUES (4, 1, 'new_4'), (5, 1, 'new_5')");
+
+ // Wait for auto-checkpoint to commit Phase 2 data to the sink. This
proves the
+ // enumerator's scan has consumed delta@20250809 and its checkpoint()
returned the
+ // advanced cursor — the exact scenario the regression would break.
+ waitForRowCount("chain_restart_sink", 7);
+
+ // Create a savepoint and stop the job atomically.
+ // stopWithSavepoint guarantees the savepoint is fully committed to
disk
+ // before returning.
+ String savepointDir = path + "/savepoints";
+ new java.io.File(savepointDir).mkdirs();
+ String checkpointPath =
+ jobClient
+ .stopWithSavepoint(
+ false,
+ savepointDir,
+
org.apache.flink.core.execution.SavepointFormatType.CANONICAL)
+ .get();
+
+ // Verify Phase 1+2 data (committed by checkpoint).
+ List<String> phase1and2 =
+ sql("SELECT * FROM chain_restart_sink").stream()
+ .map(Row::toString)
+ .collect(Collectors.toList());
+ assertThat(phase1and2)
+ .as("Phase 1+2: sink has snapshot, delta@20250808, and
delta@20250809")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, base_1, 20250808]",
+ "+I[2, 1, base_2, 20250808]",
+ "+I[3, 1, base_3, 20250808]",
+ "+I[10, 1, snap_10, 20250807]",
+ "+I[11, 1, snap_11, 20250807]",
+ "+I[4, 1, new_4, 20250809]",
+ "+I[5, 1, new_5, 20250809]");
+
+ // === Phase 3: Write new delta data while job is stopped ===
+ sql(
+ "INSERT INTO `chain_restart$branch_delta` PARTITION (dt =
'20250810')"
+ + " VALUES (6, 1, 'new_6'), (7, 1, 'new_7')");
+
+ // === Phase 4: Restart from savepoint ===
+ // The restored scan should NOT re-read delta@20250809 (already
consumed before
+ // savepoint). If checkpoint() returned the stale Phase 1 boundary,
delta@20250809
+ // would be re-read and produce duplicates.
+ sEnv.getConfig()
+ .getConfiguration()
+ .setString("execution.state-recovery.path", checkpointPath);
+
+ TableResult tableResult2 = sEnv.executeSql(streamSql);
+ //noinspection OptionalGetWithoutIsPresent
+ JobClient jobClient2 = tableResult2.getJobClient().get();
+
+ // Auto-checkpointing (1s interval) is still enabled, so data is
committed
+ // to the sink automatically. waitForRowCount polls until data appears.
+ waitForRowCount("chain_restart_sink", 9);
+
+ List<String> phase4 =
+ sql("SELECT * FROM chain_restart_sink").stream()
+ .map(Row::toString)
+ .collect(Collectors.toList());
+
+ assertThat(phase4)
+ .as("Stateful restart: sink should contain new delta data")
+ .contains("+I[6, 1, new_6, 20250810]", "+I[7, 1, new_7,
20250810]");
+
+ assertThat(phase4.size())
+ .as("Should have exactly 9 records (no duplicates from state
recovery)")
+ .isEqualTo(9);
+
+ // === Phase 5: Verify incremental streaming continues after restore
===
+ sql(
+ "INSERT INTO `chain_restart$branch_delta` PARTITION (dt =
'20250811')"
+ + " VALUES (8, 1, 'new_8')");
+
+ waitForRowCount("chain_restart_sink", 10);
+
+ List<String> phase5 =
+ sql("SELECT * FROM chain_restart_sink").stream()
+ .map(Row::toString)
+ .collect(Collectors.toList());
+ assertThat(phase5)
+ .as("Incremental streaming should continue after restore")
+ .contains("+I[8, 1, new_8, 20250811]");
+
+ jobClient2.cancel().get();
+
+ // Clean up state-recovery config for other tests
+
sEnv.getConfig().getConfiguration().removeKey("execution.state-recovery.path");
+ }
+
+ /**
+ * T1: Tests streaming read with snapshot+delta overlap in the starting
phase. Verifies that
+ * doFullLoad() correctly merges snapshot-only, delta-only, and
overlapping partitions.
+ */
+ @Test
+ @Timeout(120)
+ public void testStreamingReadWithSnapshotDeltaOverlap() throws Exception {
+ sql(
+ "CREATE TABLE chain_overlap ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_overlap', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_overlap', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_overlap", "chain_overlap$branch_snapshot",
"chain_overlap$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write snapshot data: dt=20250807 (snapshot-only) and dt=20250808
(overlapping)
+ sql(
+ "INSERT INTO `chain_overlap$branch_snapshot` PARTITION (dt =
'20250807')"
+ + " VALUES (6, 1, 'snap_6'), (7, 1, 'snap_7'), (8, 1,
'snap_8')");
+ sql(
+ "INSERT INTO `chain_overlap$branch_snapshot` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'snap_1'), (2, 1, 'snap_2'), (3, 1,
'snap_3')");
+
+ // Write delta data: dt=20250808 (overlapping) and dt=20250809
(delta-only)
+ sql(
+ "INSERT INTO `chain_overlap$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 2, 'delta_1'), (2, 2, 'delta_2'),"
+ + " (4, 1, 'delta_4'), (5, 1, 'delta_5')");
+ sql(
+ "INSERT INTO `chain_overlap$branch_delta` PARTITION (dt =
'20250809')"
+ + " VALUES (10, 1, 'new_10'), (11, 1, 'new_11')");
+
+ // Start streaming read
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_overlap").collect();
+
+ // Starting behavior (new: only output latest snapshot partition and
partitions after it):
+ // - Latest snapshot partition: dt=20250808 (k=1,2,3 from snapshot,
delta at this partition
+ // is skipped because snapshot wins for overlapping)
+ // - Delta-only partition after latest snapshot: dt=20250809 (k=10,11)
+ // - dt=20250807 is NOT output because it's before the latest snapshot
partition
+ List<String> startingRows = collectRows(it, 5);
+ assertThat(startingRows)
+ .as("Starting: latest snapshot partition and delta partitions
after it")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, snap_1, 20250808]",
+ "+I[2, 1, snap_2, 20250808]",
+ "+I[3, 1, snap_3, 20250808]",
+ "+I[10, 1, new_10, 20250809]",
+ "+I[11, 1, new_11, 20250809]");
+
+ // Incremental: write new delta and verify it streams through
+ writeChangelogToBranch(
+ db,
+ "chain_overlap",
+ "delta",
+ Row.ofKind(RowKind.INSERT, 20L, 1L, "incr_20", "20250810"));
+
+ List<String> incr = collectRows(it, 1);
+ assertThat(incr)
+ .as("Incremental: new delta data should stream through")
+ .containsExactlyInAnyOrder("+I[20, 1, incr_20, 20250810]");
+
+ it.close();
+ }
+
+ /**
+ * T2: Tests that non-default startup modes throw an error for chain table
streaming read. When
+ * scan.mode=latest is specified, an {@link UnsupportedOperationException}
is thrown with a
+ * helpful message.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadRejectsNonDefaultStartup() throws Exception {
+ sql(
+ "CREATE TABLE chain_bypass ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_bypass', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_bypass', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_bypass", "chain_bypass$branch_snapshot",
"chain_bypass$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write data to main table (so snapshots exist for copy() to resolve)
+ sql(
+ "INSERT INTO `chain_bypass$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ // Default mode: chain-table-aware scan (ChainTableStreamScan)
+ FileStoreTable table = paimonTable("chain_bypass");
+ assertThat(table.newStreamScan())
+ .as("Default startup mode should use ChainTableStreamScan")
+ .isInstanceOf(ChainTableStreamScan.class);
+
+ // scan.mode=latest: should throw UnsupportedOperationException
+ FileStoreTable tableLatest =
table.copy(Collections.singletonMap("scan.mode", "latest"));
+ assertThatThrownBy(tableLatest::newStreamScan)
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("scan.mode=latest")
+ .hasMessageContaining("Chain table streaming read does not
support")
+ .hasMessageContaining("t$branch_delta");
+ }
+
+ /**
+ * Tests that chain table streaming read rejects consumer mode. When
consumer.id is configured,
+ * an {@link UnsupportedOperationException} is thrown because chain table
streaming does not
+ * support consumer progress tracking.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadRejectsConsumerMode() throws Exception {
+ sql(
+ "CREATE TABLE chain_consumer ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_consumer', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_consumer', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_consumer",
+ "chain_consumer$branch_snapshot",
+ "chain_consumer$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ sql(
+ "INSERT INTO `chain_consumer$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ FileStoreTable table = paimonTable("chain_consumer");
+
+ // consumer.id set: should throw UnsupportedOperationException
+ FileStoreTable tableWithConsumer =
+ table.copy(Collections.singletonMap("consumer-id",
"my-consumer"));
+ assertThatThrownBy(tableWithConsumer::newStreamScan)
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("consumer mode")
+ .hasMessageContaining("consumer-id='my-consumer'")
+ .hasMessageContaining("Chain table streaming read does not
support");
+
+ // consumer-id with consumer.ignore-progress=true: still rejected
+ Map<String, String> consumerIgnoreProgressOptions = new HashMap<>();
+ consumerIgnoreProgressOptions.put("consumer-id", "my-consumer");
+ consumerIgnoreProgressOptions.put("consumer.ignore-progress", "true");
+ FileStoreTable tableWithConsumerIgnoreProgress =
+
table.copy(Collections.unmodifiableMap(consumerIgnoreProgressOptions));
+ assertThatThrownBy(tableWithConsumerIgnoreProgress::newStreamScan)
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("consumer mode");
+ }
+
+ /**
+ * T3: Tests that streaming read works with changelog-producer=none (the
default).
+ *
+ * <p>Without a changelog producer, the incremental phase reads data files
(delta manifest)
+ * rather than changelog files, producing only +I records. This is the
same behavior as standard
+ * {@code DataTableStreamScan} with {@code changelog-producer=none}.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadWithNoChangelogProducer() throws Exception {
+ // Create chain table WITHOUT changelog-producer (defaults to none)
+ sql(
+ "CREATE TABLE chain_no_cl ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_no_cl', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_no_cl', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_no_cl", "chain_no_cl$branch_snapshot",
"chain_no_cl$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Phase 1: Insert initial data into delta branch
+ sql(
+ "INSERT INTO `chain_no_cl$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ // Start streaming read and collect Phase 1 results
+ try (CloseableIterator<Row> it =
+ sEnv.executeSql("SELECT k, v, dt FROM chain_no_cl").collect())
{
+ List<String> phase1 = collectRows(it, 2);
+ assertThat(phase1)
+ .containsExactlyInAnyOrder("+I[1, v1, 20250808]", "+I[2,
v2, 20250808]");
+
+ // Phase 2: Insert more data into delta branch (incremental)
+ sql(
+ "INSERT INTO `chain_no_cl$branch_delta` PARTITION (dt =
'20250809')"
+ + " VALUES (3, 1, 'v3')");
+
+ List<String> phase2 = collectRows(it, 1);
+ assertThat(phase2).containsExactly("+I[3, v3, 20250809]");
+ }
+ }
+
+ /**
+ * T6: Tests streaming read with group partitions (chain-partition-keys).
Verifies that
+ * streaming works correctly when the table has a group dimension (e.g.,
region) and each group
+ * maintains its own independent chain.
+ */
+ @Test
+ @Timeout(120)
+ public void testStreamingReadWithGroupPartition() throws Exception {
+ sql(
+ "CREATE TABLE chain_stream_group ("
+ + " k BIGINT, seq BIGINT, v STRING, region STRING, dt
STRING"
+ + ") PARTITIONED BY (region, dt) WITH ("
+ + " 'primary-key' = 'region,dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'chain-table.chain-partition-keys' = 'dt',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_stream_group', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_stream_group', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_stream_group",
+ "chain_stream_group$branch_snapshot",
+ "chain_stream_group$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write initial delta data for two regions
+ sql(
+ "INSERT INTO `chain_stream_group$branch_delta`"
+ + " PARTITION (region = 'CN', dt = '20250808')"
+ + " VALUES (1, 1, 'cn_1'), (2, 1, 'cn_2')");
+ sql(
+ "INSERT INTO `chain_stream_group$branch_delta`"
+ + " PARTITION (region = 'US', dt = '20250808')"
+ + " VALUES (11, 1, 'us_11'), (12, 1, 'us_12')");
+
+ // Start streaming read
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_stream_group").collect();
+
+ // Starting: both regions, delta-only
+ List<String> startingRows = collectRows(it, 4);
+ assertThat(startingRows)
+ .as("Starting: delta-only data for both regions")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, cn_1, CN, 20250808]",
+ "+I[2, 1, cn_2, CN, 20250808]",
+ "+I[11, 1, us_11, US, 20250808]",
+ "+I[12, 1, us_12, US, 20250808]");
+
+ // Incremental: write new delta for CN only
+ sql(
+ "INSERT INTO `chain_stream_group$branch_delta`"
+ + " PARTITION (region = 'CN', dt = '20250809')"
+ + " VALUES (3, 1, 'cn_3')");
+
+ List<String> incr = collectRows(it, 1);
+ assertThat(incr)
+ .as("Incremental: new CN delta should stream through")
+ .containsExactlyInAnyOrder("+I[3, 1, cn_3, CN, 20250809]");
+
+ // Incremental: write new delta for US
+ sql(
+ "INSERT INTO `chain_stream_group$branch_delta`"
+ + " PARTITION (region = 'US', dt = '20250809')"
+ + " VALUES (13, 1, 'us_13')");
+
+ List<String> incr2 = collectRows(it, 1);
+ assertThat(incr2)
+ .as("Incremental: new US delta should stream through")
+ .containsExactlyInAnyOrder("+I[13, 1, us_13, US, 20250809]");
+
+ it.close();
+ }
+
+ //
=========================================================================
+ // Additional coverage tests
+ //
=========================================================================
+
+ /** Tests restore(id, scanAll=true): resets starting state but preserves
delta position. */
+ @Test
+ @Timeout(60)
+ public void testRestoreScanAll() throws Exception {
+ sql(
+ "CREATE TABLE chain_restore_all ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_restore_all', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_restore_all', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_restore_all",
+ "chain_restore_all$branch_snapshot",
+ "chain_restore_all$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ sql(
+ "INSERT INTO `chain_restore_all$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ FileStoreTable table = paimonTable("chain_restore_all");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ // Phase 1: starting
+ TableScan.Plan plan1 = scan.plan();
+ assertThat(plan1.splits()).as("Phase 1 should produce
splits").isNotEmpty();
+ Long checkpoint = scan.checkpoint();
+ assertThat(checkpoint).as("Checkpoint should be non-null after Phase
1").isNotNull();
+
+ // Phase 2: no new data → empty plan
+ TableScan.Plan plan2 = scan.plan();
+ assertThat(plan2.splits()).as("Phase 2 with no new data should be
empty").isEmpty();
+
+ // restore(id, scanAll=true): should reset to starting, preserve delta
position
+ scan.restore(checkpoint, true);
+ assertThat(scan.checkpoint())
+ .as("Checkpoint should be preserved after restore(id, true)")
+ .isEqualTo(checkpoint);
+
+ // Starting should run again
+ TableScan.Plan plan3 = scan.plan();
+ assertThat(plan3.splits())
+ .as("Starting should produce splits again after restore(id,
true)")
+ .isNotEmpty();
+
+ // Delta position should be the same (no new commits)
+ assertThat(scan.checkpoint()).as("Checkpoint should remain the
same").isEqualTo(checkpoint);
+ }
+
+ /** Tests restore(null, true): resets to fresh starting with no delta
position. */
+ @Test
+ @Timeout(60)
+ public void testRestoreNullScanAll() throws Exception {
+ sql(
+ "CREATE TABLE chain_restore_null ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_restore_null', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_restore_null', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_restore_null",
+ "chain_restore_null$branch_snapshot",
+ "chain_restore_null$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ sql(
+ "INSERT INTO `chain_restore_null$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1')");
+
+ FileStoreTable table = paimonTable("chain_restore_null");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ // Phase 1: starting
+ scan.plan();
+ assertThat(scan.checkpoint()).isNotNull();
+
+ // restore(null, scanAll=true): fresh start, no delta position
+ scan.restore(null, true);
+ assertThat(scan.checkpoint())
+ .as("Checkpoint should be null after restore(null, true)")
+ .isNull();
+
+ // Starting should run again
+ TableScan.Plan plan = scan.plan();
+ assertThat(plan.splits()).as("Starting should produce
splits").isNotEmpty();
+ assertThat(scan.checkpoint()).as("Checkpoint should be set after new
starting").isNotNull();
+ }
+
+ /** Tests starting when delta branch is empty (only snapshot data). */
+ @Test
+ @Timeout(120)
+ public void testStreamingReadEmptyDelta() throws Exception {
+ sql(
+ "CREATE TABLE chain_empty_delta ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_empty_delta', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_empty_delta', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_empty_delta",
+ "chain_empty_delta$branch_snapshot",
+ "chain_empty_delta$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write ONLY to snapshot branch, delta stays empty
+ sql(
+ "INSERT INTO `chain_empty_delta$branch_snapshot` PARTITION (dt
= '20250807')"
+ + " VALUES (1, 1, 'snap_1'), (2, 1, 'snap_2')");
+
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_empty_delta").collect();
+
+ List<String> startingRows = collectRows(it, 2);
+ assertThat(startingRows)
+ .as("Starting with empty delta should return only snapshot
data")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, snap_1, 20250807]", "+I[2, 1, snap_2,
20250807]");
+
+ // Incremental: write to delta and verify it streams through
+ writeChangelogToBranch(
+ db,
+ "chain_empty_delta",
+ "delta",
+ Row.ofKind(RowKind.INSERT, 3L, 1L, "new_3", "20250808"));
+
+ List<String> incr = collectRows(it, 1);
+ assertThat(incr)
+ .as("First delta write should stream through after
snapshot-only starting")
+ .containsExactlyInAnyOrder("+I[3, 1, new_3, 20250808]");
+
+ it.close();
+ }
+
+ /** Tests starting when snapshot branch is empty (only delta data). */
+ @Test
+ @Timeout(120)
+ public void testStreamingReadEmptySnapshot() throws Exception {
+ sql(
+ "CREATE TABLE chain_empty_snap ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_empty_snap', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_empty_snap', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_empty_snap",
+ "chain_empty_snap$branch_snapshot",
+ "chain_empty_snap$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write ONLY to delta branch, snapshot stays empty
+ sql(
+ "INSERT INTO `chain_empty_snap$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'delta_1'), (2, 1, 'delta_2')");
+ sql(
+ "INSERT INTO `chain_empty_snap$branch_delta` PARTITION (dt =
'20250809')"
+ + " VALUES (3, 1, 'delta_3')");
+
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_empty_snap").collect();
+
+ List<String> startingRows = collectRows(it, 3);
+ assertThat(startingRows)
+ .as("Starting with empty snapshot should return only delta
data")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, delta_1, 20250808]",
+ "+I[2, 1, delta_2, 20250808]",
+ "+I[3, 1, delta_3, 20250809]");
+
+ it.close();
+ }
+
+ /** Tests that withShard() is correctly forwarded to both batch scan and
delta stream scan. */
+ @Test
+ @Timeout(60)
+ public void testWithShardForwarding() throws Exception {
+ sql(
+ "CREATE TABLE chain_shard ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_shard', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_shard', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_shard", "chain_shard$branch_snapshot",
"chain_shard$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ sql(
+ "INSERT INTO `chain_shard$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ FileStoreTable table = paimonTable("chain_shard");
+
+ // Shard 0 of 2: should get a subset of data
+ ChainTableStreamScan scan0 = (ChainTableStreamScan)
table.newStreamScan();
+ scan0.withShard(0, 2);
+ TableScan.Plan plan0 = scan0.plan();
+
+ // Shard 1 of 2: should get the other subset
+ ChainTableStreamScan scan1 = (ChainTableStreamScan)
table.newStreamScan();
+ scan1.withShard(1, 2);
+ TableScan.Plan plan1 = scan1.plan();
+
+ // Together both shards should produce non-empty results
+ // (exact split depends on bucket hashing, but total should cover all
data)
+ int totalSplits = plan0.splits().size() + plan1.splits().size();
+ assertThat(totalSplits).as("Both shards together should produce
splits").isGreaterThan(0);
+ }
+
+ /** Tests streaming read when both snapshot and delta branches are empty.
*/
+ @Test
+ @Timeout(60)
+ public void testStreamingReadBothBranchesEmpty() throws Exception {
+ sql(
+ "CREATE TABLE chain_both_empty ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_both_empty', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_both_empty', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_both_empty",
+ "chain_both_empty$branch_snapshot",
+ "chain_both_empty$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Both branches are empty — Phase 1 should produce no splits
+ FileStoreTable table = paimonTable("chain_both_empty");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+ TableScan.Plan plan1 = scan.plan();
+ assertThat(plan1.splits()).as("Phase 1 with both branches empty should
be empty").isEmpty();
+
+ // Phase 2: write new delta data and verify it streams through
+ sql(
+ "INSERT INTO `chain_both_empty$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ TableScan.Plan plan2 = scan.plan();
+ assertThat(plan2.splits()).as("Phase 2 should pick up new delta
data").isNotEmpty();
+ }
+
+ /** Tests that delta OVERWRITE in Phase 2 does not crash the scan. */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadDeltaOverwriteInPhase2() throws Exception {
+ sql(
+ "CREATE TABLE chain_overwrite_p2 ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_overwrite_p2', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_overwrite_p2', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_overwrite_p2",
+ "chain_overwrite_p2$branch_snapshot",
+ "chain_overwrite_p2$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Initial delta data
+ sql(
+ "INSERT INTO `chain_overwrite_p2$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+ FileStoreTable table = paimonTable("chain_overwrite_p2");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ // Phase 1: read initial delta data
+ TableScan.Plan plan1 = scan.plan();
+ assertThat(plan1.splits()).as("Phase 1 should produce
splits").isNotEmpty();
+
+ // Phase 2: OVERWRITE the same partition on delta branch.
+ // This creates a snapshot with OVERWRITE kind. The scan should handle
it
+ // gracefully — either producing data or empty plans, but never
crashing.
+ sql(
+ "INSERT OVERWRITE `chain_overwrite_p2$branch_delta` PARTITION
(dt = '20250808')"
+ + " VALUES (1, 2, 'new_v1'), (3, 1, 'v3')");
+
+ // Verify scan.plan() does not throw after OVERWRITE
+ for (int i = 0; i < 3; i++) {
+ TableScan.Plan planN = scan.plan();
+ assertThat(planN).as("plan() should not return null after
OVERWRITE").isNotNull();
+ }
+ }
+
+ /** Tests restore(null) re-runs Phase 1 with current data state. */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadRestoreAfterNewData() throws Exception {
+ sql(
+ "CREATE TABLE chain_restore_newdata ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_restore_newdata', 'snapshot')",
db);
+ sql("CALL sys.create_branch('%s.chain_restore_newdata', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_restore_newdata",
+ "chain_restore_newdata$branch_snapshot",
+ "chain_restore_newdata$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write initial snapshot + delta data
+ sql(
+ "INSERT INTO `chain_restore_newdata$branch_snapshot` PARTITION
(dt = '20250807')"
+ + " VALUES (1, 1, 'snap_1')");
+ sql(
+ "INSERT INTO `chain_restore_newdata$branch_delta` PARTITION
(dt = '20250808')"
+ + " VALUES (2, 1, 'delta_2')");
+
+ FileStoreTable table = paimonTable("chain_restore_newdata");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ // Phase 1: snapshot at dt=20250807 (latest), delta at dt=20250808
+ TableScan.Plan plan1 = scan.plan();
+ int phase1Size = plan1.splits().size();
+ assertThat(phase1Size).as("Phase 1 should produce
splits").isGreaterThan(0);
+
+ // Add more data to both branches
+ sql(
+ "INSERT INTO `chain_restore_newdata$branch_snapshot` PARTITION
(dt = '20250809')"
+ + " VALUES (3, 1, 'snap_3')");
+ sql(
+ "INSERT INTO `chain_restore_newdata$branch_delta` PARTITION
(dt = '20250810')"
+ + " VALUES (4, 1, 'delta_4')");
+
+ // restore(null) resets to fresh starting — Phase 1 should re-run with
new data.
+ // After adding snapshot dt=20250809 and delta dt=20250810:
+ // - Latest snapshot: dt=20250809 (dt=20250807 excluded as older)
+ // - Delta dt=20250808 excluded (older than latest snapshot
dt=20250809)
+ // - Delta dt=20250810 included (newer than dt=20250809)
+ scan.restore(null);
+ TableScan.Plan plan2 = scan.plan();
+ assertThat(plan2.splits())
+ .as("Restore(null) should re-run Phase 1 with current data")
+ .isNotEmpty();
+ }
+
+ /**
+ * T5: Tests that chain table streaming read rejects partition filters via
{@code withFilter}.
+ *
+ * <p>Partition filters interfere with the chain table Phase 1 logic
(which determines the
+ * latest snapshot partition per group). This test verifies that a
partition-only predicate is
+ * rejected with an UnsupportedOperationException.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadRejectsPartitionFilter() throws Exception {
+ createChainTable("chain_pf_partition");
+ setupChainTableBranches("chain_pf_partition");
+
+ sql(
+ "INSERT INTO `chain_pf_partition$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1')");
+
+ FileStoreTable table = paimonTable("chain_pf_partition");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ // dt is the 4th field (index 3) in the schema: t1(0), t2(1), t3(2),
dt(3)
+ PredicateBuilder builder = new PredicateBuilder(table.rowType());
+
+ // Partition-only filter should be rejected
+ Predicate partitionFilter = builder.equal(3,
BinaryString.fromString("20250808"));
+ assertThatThrownBy(() -> scan.withFilter(partitionFilter))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Partition filter is not supported");
+ }
+
+ /**
+ * Tests that chain table streaming read rejects mixed predicates that
contain partition
+ * conjuncts.
+ *
+ * <p>A predicate like {@code dt = '20250808' AND v = 'hello'} combines a
partition filter with
+ * a data filter. In Flink, such predicates are pushed down as a single
AND expression. The
+ * chain table stream scan must reject any predicate that contains
partition fields, because the
+ * partition conjunct would be extracted later and interfere with the
chain boundary computation
+ * in Phase 1.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadRejectsMixedPredicateWithPartition() throws
Exception {
+ createChainTable("chain_pf_mixed");
+ setupChainTableBranches("chain_pf_mixed");
+
+ sql(
+ "INSERT INTO `chain_pf_mixed$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'hello')");
+
+ FileStoreTable table = paimonTable("chain_pf_mixed");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ PredicateBuilder builder = new PredicateBuilder(table.rowType());
+ // dt is the 4th field (index 3), v is the 3rd field (index 2)
+ Predicate dtEquals = builder.equal(3,
BinaryString.fromString("20250808"));
+ Predicate vEquals = builder.equal(2, BinaryString.fromString("hello"));
+ // Mixed predicate: partition AND data field
+ Predicate mixedPredicate = builder.and(dtEquals, vEquals);
+
+ // Should be rejected because it contains a partition conjunct
+ assertThatThrownBy(() -> scan.withFilter(mixedPredicate))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Partition filter is not supported");
+ }
+
+ /**
+ * T6: Tests that non-partition filters work end-to-end in chain table
streaming reads.
+ *
+ * <p>Verifies: (1) {@code withFilter} on a data column is accepted at the
scan API level, (2)
+ * streaming {@code SELECT ... WHERE v = 'hello'} filters out non-matching
rows, (3) the filter
+ * continues to apply to incrementally written data.
+ */
+ @Test
+ @Timeout(120)
+ public void testStreamingReadWithNonPartitionFilter() throws Exception {
+ sql(
+ "CREATE TABLE chain_data_filter ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'changelog-producer' = 'input',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_data_filter', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_data_filter', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_data_filter",
+ "chain_data_filter$branch_snapshot",
+ "chain_data_filter$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta')",
+ tbl);
+ }
+
+ // Write initial delta data with mixed values of v
+ sql(
+ "INSERT INTO `chain_data_filter$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'hello'), (2, 1, 'world'), (3, 1,
'hello'), (4, 1, 'foo')");
+
+ // Streaming read with WHERE on data column v — should only return
v='hello' rows
+ CloseableIterator<Row> it =
+ sEnv.executeSql("SELECT * FROM chain_data_filter WHERE v =
'hello'").collect();
+
+ List<String> startingRows = collectRows(it, 2);
+ assertThat(startingRows)
+ .as("Starting with WHERE v='hello' should only return matching
rows")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, hello, 20250808]", "+I[3, 1, hello,
20250808]");
+
+ // Incremental: write more data with mixed v values
+ writeChangelogToBranch(
+ db,
+ "chain_data_filter",
+ "delta",
+ Row.ofKind(RowKind.INSERT, 5L, 1L, "hello", "20250809"),
+ Row.ofKind(RowKind.INSERT, 6L, 1L, "bar", "20250809"));
+
+ List<String> incrRows = collectRows(it, 1);
+ assertThat(incrRows)
+ .as("Incremental: only v='hello' row should stream through")
+ .containsExactlyInAnyOrder("+I[5, 1, hello, 20250809]");
+
+ it.close();
+ }
+
+ /**
+ * T7: Tests that chain table streaming read rejects partition filters via
{@code
+ * withPartitionFilter}.
+ *
+ * <p>The {@code withPartitionFilter} API (used for {@code
scan.partitions} table option) should
+ * also be rejected.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadRejectsWithPartitionFilter() throws Exception
{
+ createChainTable("chain_pf_api");
+ setupChainTableBranches("chain_pf_api");
+
+ sql(
+ "INSERT INTO `chain_pf_api$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1')");
+
+ FileStoreTable table = paimonTable("chain_pf_api");
+ ChainTableStreamScan scan = (ChainTableStreamScan)
table.newStreamScan();
+
+ // withPartitionFilter(Map) should be rejected
+ assertThatThrownBy(
+ () ->
scan.withPartitionFilter(Collections.singletonMap("dt", "20250808")))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Partition filter is not supported");
+
+ // withPartitionFilter(PartitionPredicate) should be rejected
+ PredicateBuilder ppBuilder = new
PredicateBuilder(table.schema().logicalPartitionType());
+ PartitionPredicate pp =
+ PartitionPredicate.fromPredicate(
+ table.schema().logicalPartitionType(),
+ ppBuilder.equal(0,
BinaryString.fromString("20250808")));
+ assertThatThrownBy(() -> scan.withPartitionFilter(pp))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining("Partition filter is not supported");
+ }
+
+ /**
+ * Tests that chain table streaming rejects checkpoint-align mode at job
construction time, not
+ * at runtime. ChainSplit has no snapshotId and cannot participate in
snapshot-aligned
+ * checkpoint grouping.
+ */
+ @Test
+ public void testStreamingReadRejectsCheckpointAlign() throws Exception {
+ createChainTable("chain_align");
+ setupChainTableBranches("chain_align");
+
+ sql(
+ "INSERT INTO `chain_align$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1')");
+
+ // Setting checkpoint-align.enabled on a chain table streaming read
should throw
+ // at job construction time, not at runtime when ChainSplits are
encountered.
+ assertThatThrownBy(
+ () ->
+ sEnv.executeSql(
+ "SELECT * FROM chain_align "
+ + "/*+
OPTIONS('source.checkpoint-align.enabled' = 'true') */"))
+ .isInstanceOf(UnsupportedOperationException.class)
+ .hasMessageContaining(
+ "Chain table streaming is not compatible with
checkpoint-align");
+ }
+
+ /**
+ * Tests that primary-key predicates do NOT affect partition discovery in
chain table streaming
+ * Phase 1. This is the scenario from JingsongLi's review comment:
+ *
+ * <p>"if the latest snapshot partition no longer has k=1 but an older
delta partition still
+ * does, SELECT ... WHERE k=1 can make this listing miss the latest
snapshot partition and then
+ * include the old delta row, even though that partition should be
considered outdated."
+ *
+ * <p>The test creates: snapshot@20250808 with t1=1,2; snapshot@20250809
with t1=3,4 (no t1=1);
+ * delta@20250808 with t1=1. Then filters on t1=1 (a primary key field).
Partition discovery
+ * must still see both snapshot partitions so the chain boundary is
correct.
+ */
+ @Test
+ public void testStreamingReadPKFilterDoesNotAffectPartitionDiscovery()
throws Exception {
+ createChainTable("chain_pk_filter");
+ setupChainTableBranches("chain_pk_filter");
+
+ // Snapshot@20250808: has t1=1 and t1=2
+ sql(
+ "INSERT INTO `chain_pk_filter$branch_snapshot` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+ // Snapshot@20250809: has t1=3 and t1=4 (NO t1=1)
+ sql(
+ "INSERT INTO `chain_pk_filter$branch_snapshot` PARTITION (dt =
'20250809')"
+ + " VALUES (3, 1, 'v3'), (4, 1, 'v4')");
+
+ // Delta@20250808: has t1=1
+ sql(
+ "INSERT INTO `chain_pk_filter$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 2, 'delta_v1')");
+
+ // --- Part 1: Verify listPartitions() with a PK predicate ---
+ FileStoreTable mainTable = paimonTable("chain_pk_filter");
+ FileStoreTable snapshotTable =
+
mainTable.copy(Collections.singletonMap(CoreOptions.BRANCH.key(), "snapshot"));
+
+ DataTableScan scan = snapshotTable.newScan();
+ PredicateBuilder builder = new
PredicateBuilder(snapshotTable.rowType());
+ // t1 is field index 0, part of primary key (dt, t1).
+ // Only snapshot@20250808 has t1=1.
+ Predicate t1Equals1 = builder.equal(0, 1L);
+ scan.withFilter(t1Equals1);
+
+ // listPartitions() must return BOTH snapshot partitions even though
only
+ // 20250808 contains t1=1. If it returned only 20250808, the chain
boundary
+ // would be wrong and stale data could be included.
+ List<BinaryRow> partitions = scan.listPartitions();
+ assertThat(partitions)
+ .as(
+ "listPartitions() must return all snapshot partitions
even with a PK filter. "
+ + "If only dt=20250808 is returned, the chain
boundary is wrong.")
+ .hasSize(2);
+
+ // --- Part 2: Verify filtered batch SELECT returns correct data ---
+ // Chain-merged batch view: snapshot@20250808(t1=1,2),
snapshot@20250809(t1=3,4).
+ // WHERE t1 = 1 should return only the snapshot row (t1=1,
dt=20250808).
+ List<String> filtered = collectResult("SELECT * FROM chain_pk_filter
WHERE t1 = 1");
+ assertThat(filtered)
+ .as("WHERE t1=1 should find the snapshot row at dt=20250808")
+ .hasSize(1)
+ .containsExactly("+I[1, 1, v1, 20250808]");
+ }
+
+ /**
+ * Reproduces the snapshot branch race condition from PR comment: Phase 1
pins delta at latestId
+ * but snapshot branch is read from "whatever is latest". If a snapshot
commit lands between
+ * capturing delta and listing partitions, Phase 1 excludes old delta
data, and Phase 2 starts
+ * from latestId+1, so the old delta is never emitted.
+ *
+ * <p>This test simulates the race by: 1) writing delta data, 2) capturing
delta position, 3)
+ * writing new snapshot data, 4) verifying the delta data is still emitted
in Phase 1 or Phase
+ * 2.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadSnapshotBranchRaceCondition() throws
Exception {
+ sql(
+ "CREATE TABLE chain_race ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_race', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_race', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_race", "chain_race$branch_snapshot",
"chain_race$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta'"
+ + ")",
+ tbl);
+ }
+
+ // Step 1: Write delta data at dt=20250808
+ sql(
+ "INSERT INTO `chain_race$branch_delta` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'delta_1'), (2, 1, 'delta_2')");
+
+ // Step 2: Start streaming job (captures delta position)
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_race").collect();
+
+ // Step 3: Collect Phase 1 output (should include delta@20250808)
+ List<String> phase1 = collectRows(it, 2);
+ assertThat(phase1)
+ .as("Phase 1 should include delta@20250808 data")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, delta_1, 20250808]", "+I[2, 1, delta_2,
20250808]");
+
+ // Step 4: Write NEW snapshot data at dt=20250809 (simulates race
condition)
+ sql(
+ "INSERT INTO `chain_race$branch_snapshot` PARTITION (dt =
'20250809')"
+ + " VALUES (3, 1, 'snap_3')");
+
+ // Step 5: Write NEW delta data at dt=20250810
+ sql(
+ "INSERT INTO `chain_race$branch_delta` PARTITION (dt =
'20250810')"
+ + " VALUES (4, 1, 'delta_4')");
+
+ // Step 6: Collect Phase 2 output (should include delta@20250810)
+ // BUG: If snapshot branch was not pinned, Phase 1 might have excluded
delta@20250808
+ // after seeing snapshot@20250809, and Phase 2 would miss it too.
+ List<String> phase2 = collectRows(it, 1);
+ assertThat(phase2)
+ .as("Phase 2 should include new delta@20250810 data")
+ .containsExactlyInAnyOrder("+I[4, 1, delta_4, 20250810]");
+
+ it.close();
+ }
+
+ /**
+ * Reproduces the Phase 2 read bypass issue from PR comment: after Phase
1, the stream emits
+ * normal delta DataSplits from deltaStreamScan.plan(). But
ChainTableFileStoreTable inherits
+ * FallbackReadFileStoreTable.newRead(), whose non-FallbackSplit path
falls back to
+ * mainRead.createReader(split), bypassing the branch-aware
ChainGroupReadTable.Read logic.
+ *
+ * <p>This test creates a chain table where delta branch has a different
column default than
+ * snapshot branch, then verifies Phase 2 reads produce correct results
with branch-aware schema
+ * lookup.
+ */
+ @Test
+ @Timeout(60)
+ public void testStreamingReadPhase2BranchAwareRead() throws Exception {
+ sql(
+ "CREATE TABLE chain_phase2 ("
+ + " k BIGINT, seq BIGINT, v STRING, dt STRING"
+ + ") PARTITIONED BY (dt) WITH ("
+ + " 'primary-key' = 'dt,k',"
+ + " 'bucket-key' = 'k',"
+ + " 'bucket' = '2',"
+ + " 'sequence.field' = 'seq',"
+ + " 'merge-engine' = 'deduplicate',"
+ + " 'chain-table.enabled' = 'true',"
+ + " 'partition.timestamp-pattern' = '$dt',"
+ + " 'partition.timestamp-formatter' = 'yyyyMMdd',"
+ + " 'continuous.discovery-interval' = '1ms'"
+ + ")");
+
+ String db = tEnv.getCurrentDatabase();
+ sql("CALL sys.create_branch('%s.chain_phase2', 'snapshot')", db);
+ sql("CALL sys.create_branch('%s.chain_phase2', 'delta')", db);
+ for (String tbl :
+ new String[] {
+ "chain_phase2", "chain_phase2$branch_snapshot",
"chain_phase2$branch_delta"
+ }) {
+ sql(
+ "ALTER TABLE `%s` SET ("
+ + " 'scan.fallback-snapshot-branch' = 'snapshot',"
+ + " 'scan.fallback-delta-branch' = 'delta'"
+ + ")",
+ tbl);
+ }
+
+ // Write snapshot data at dt=20250808
+ sql(
+ "INSERT INTO `chain_phase2$branch_snapshot` PARTITION (dt =
'20250808')"
+ + " VALUES (1, 1, 'snap_1')");
+
+ // Write delta data at dt=20250809 (different partition, will be read
in Phase 2)
+ sql(
+ "INSERT INTO `chain_phase2$branch_delta` PARTITION (dt =
'20250809')"
+ + " VALUES (2, 1, 'delta_2')");
+
+ // Start streaming and collect Phase 1 (snapshot@20250808 +
delta@20250809)
+ CloseableIterator<Row> it = sEnv.executeSql("SELECT * FROM
chain_phase2").collect();
+ List<String> phase1 = collectRows(it, 2);
+ assertThat(phase1)
+ .as("Phase 1 should include both snapshot and delta data")
+ .containsExactlyInAnyOrder(
+ "+I[1, 1, snap_1, 20250808]", "+I[2, 1, delta_2,
20250809]");
+
+ // Write NEW delta data at dt=20250810 (will be read in Phase 2)
+ sql(
+ "INSERT INTO `chain_phase2$branch_delta` PARTITION (dt =
'20250810')"
+ + " VALUES (3, 1, 'delta_3')");
+
+ // Collect Phase 2 output
+ // BUG: If Phase 2 read bypasses branch-aware logic, this might fail
or produce
+ // wrong results when snapshot/delta schemas diverge.
+ List<String> phase2 = collectRows(it, 1);
+ assertThat(phase2)
+ .as("Phase 2 should correctly read delta@20250810 with
branch-aware logic")
+ .containsExactlyInAnyOrder("+I[3, 1, delta_3, 20250810]");
+
+ it.close();
+ }
}