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 984f359068 [flink] Support lookup join for Chain Table (#8423)
984f359068 is described below

commit 984f359068b4ab9ef7ab7b991b4b79eca08cf111
Author: yunfengzhou-hub <[email protected]>
AuthorDate: Mon Jul 6 20:48:13 2026 +0800

    [flink] Support lookup join for Chain Table (#8423)
    
    This PR adds support for Flink lookup join on Chain Table. When used as
    a dimension table, the lookup join reflects the latest state of the
    chain table: the most recent snapshot partition per group, combined with
    delta partitions that come after it.
---
 docs/docs/primary-key-table/chain-table.mdx        |   56 +-
 .../paimon/io/ChainKeyValueFileReaderFactory.java  |    2 +-
 .../apache/paimon/table/ChainGroupReadTable.java   |   87 +-
 .../apache/paimon/table/ChainTableStreamScan.java  |   54 +-
 .../org/apache/paimon/table/source/ChainSplit.java |   16 +
 .../org/apache/paimon/utils/ChainTableUtils.java   |   17 +
 .../flink/lookup/FileStoreLookupFunction.java      |   25 +-
 .../paimon/flink/lookup/FullCacheLookupTable.java  |   29 +-
 .../paimon/flink/lookup/LookupFileStoreTable.java  |   53 +
 .../paimon/flink/lookup/LookupStreamingReader.java |   38 +-
 .../flink/lookup/PrimaryKeyPartialLookupTable.java |    2 +-
 .../apache/paimon/flink/FlinkChainTableITCase.java | 1423 +++++++++++++++++++-
 12 files changed, 1756 insertions(+), 46 deletions(-)

diff --git a/docs/docs/primary-key-table/chain-table.mdx 
b/docs/docs/primary-key-table/chain-table.mdx
index ada14470ec..9556434692 100644
--- a/docs/docs/primary-key-table/chain-table.mdx
+++ b/docs/docs/primary-key-table/chain-table.mdx
@@ -95,14 +95,14 @@ CREATE TABLE default.t (
     `t1` STRING,
     `t2` STRING,
     `t3` STRING,
-    `dt` STRING
-) PARTITIONED BY (`dt`) WITH (
+    `date` STRING
+) PARTITIONED BY (`date`) WITH (
   'chain-table.enabled' = 'true',
-  'primary-key' = 'dt,t1',
+  'primary-key' = 'date,t1',
   'sequence.field' = 't2',
   'bucket-key' = 't1',
   'bucket' = '2',
-  'partition.timestamp-pattern' = '$dt', 
+  'partition.timestamp-pattern' = '$date',
   'partition.timestamp-formatter' = 'yyyyMMdd'
 );
 
@@ -130,6 +130,9 @@ 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.
 - Deletion vector is not supported for chain table.
+- The delta branch must use the `DEDUPLICATE` merge engine (default) if you 
plan
+  to use streaming read or lookup join. Other merge engine types are not 
supported
+  on the delta branch for these incremental read paths. Batch read is not 
affected.
 
 After creating a chain table, you can read and write data in the following 
ways.
 
@@ -263,6 +266,51 @@ INSERT INTO downstream_sink SELECT * FROM default.t;
   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.
+- The delta branch must use the `DEDUPLICATE` merge engine (default). Other 
merge engine
+  types are not supported.
+
+## Lookup Join
+
+Chain tables support Flink lookup joins. When used as a dimension table, the 
lookup
+join reflects the latest state of the chain table: the most recent snapshot 
partition
+per group, combined with delta partitions that come after it. Older snapshot
+partitions are considered outdated and excluded. After the initial load, new 
delta
+branch writes become visible through periodic incremental refresh.
+
+```sql
+SELECT
+    orders.order_id,
+    dim.product_name
+FROM orders
+LEFT JOIN dim /*+ OPTIONS('continuous.discovery-interval' = '5s') */
+    FOR SYSTEM_TIME AS OF orders.proc_time AS dim
+    ON orders.product_id = dim.product_id;
+```
+
+The incremental refresh only monitors the **delta branch**. Writes to the 
snapshot
+branch are not detected until the lookup join job is restarted.
+
+### Limitations
+
+- The incremental refresh only monitors the **delta branch**. Writes to the 
snapshot
+  branch are not detected until the lookup join job is restarted.
+- Join key must not contain partition keys. Chain table's partition model 
spans two
+  branches (snapshot + delta) with anchor-based merging, making 
partition-level routing
+  unreliable for lookup joins. If partition keys appear in the join condition, 
an error
+  will be raised.
+- Partition filters are not supported in chain table lookup joins. Specifying a
+  partition filter via the `scan.partitions` table option throws an
+  `UnsupportedOperationException`. This is because the chain table lookup 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.
+- The chain-table-aware lookup 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.
+- `lookup.cache` must be `AUTO` or `FULL`. Other cache modes are not supported 
for
+  chain tables.
+- The delta branch must use the `DEDUPLICATE` merge engine (default). Other 
merge
+  engine types are not supported.
 
 ## Group Partition
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/io/ChainKeyValueFileReaderFactory.java
 
b/paimon-core/src/main/java/org/apache/paimon/io/ChainKeyValueFileReaderFactory.java
index d4e98af85e..a06028b2e0 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/io/ChainKeyValueFileReaderFactory.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/io/ChainKeyValueFileReaderFactory.java
@@ -88,7 +88,7 @@ public class ChainKeyValueFileReaderFactory extends 
KeyValueFileReaderFactory {
     protected TableSchema getDataSchema(DataFileMeta fileMeta) {
         String branch = 
chainReadContext.fileBranchMapping().get(fileMeta.fileName());
         if (currentBranch.equalsIgnoreCase(branch)) {
-            super.getDataSchema(fileMeta);
+            return super.getDataSchema(fileMeta);
         }
         if (!branchSchemaManagers.containsKey(branch)) {
             throw new RuntimeException("No schema manager found for branch: " 
+ branch);
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 c65c287aec..1428d26e9d 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
@@ -47,6 +47,7 @@ import org.apache.paimon.utils.RowDataToObjectArrayConverter;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -66,6 +67,29 @@ import static 
org.apache.paimon.utils.Preconditions.checkNotNull;
  */
 public class ChainGroupReadTable extends FallbackReadFileStoreTable {
 
+    /**
+     * Options that cannot be safely passed to branch tables during copy 
because they would
+     * interfere with branch-specific configurations. If dynamic options 
contain any of these, an
+     * error is thrown since we cannot fulfill the copy contract (which 
expects all options to be
+     * passed through).
+     */
+    private static final Set<String> UNSAFE_COPY_OPTIONS =
+            Collections.unmodifiableSet(
+                    new HashSet<>(
+                            Arrays.asList(
+                                    CoreOptions.MERGE_ENGINE.key(),
+                                    CoreOptions.BUCKET.key(),
+                                    CoreOptions.BUCKET_KEY.key(),
+                                    CoreOptions.SEQUENCE_FIELD.key(),
+                                    CoreOptions.SCAN_MODE.key(),
+                                    CoreOptions.SCAN_SNAPSHOT_ID.key(),
+                                    CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+                                    CoreOptions.SCAN_TIMESTAMP.key(),
+                                    
CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
+                                    CoreOptions.SCAN_TAG_NAME.key(),
+                                    CoreOptions.SCAN_VERSION.key(),
+                                    CoreOptions.SCAN_WATERMARK.key())));
+
     public ChainGroupReadTable(FileStoreTable snapshotStoreTable, 
FileStoreTable deltaStoreTable) {
         super(snapshotStoreTable, deltaStoreTable, true);
         checkArgument(snapshotStoreTable instanceof PrimaryKeyFileStoreTable);
@@ -88,22 +112,49 @@ public class ChainGroupReadTable extends 
FallbackReadFileStoreTable {
 
     @Override
     public FileStoreTable copy(Map<String, String> dynamicOptions) {
-        return new ChainGroupReadTable(
-                wrapped.copy(dynamicOptions), 
other().copy(rewriteOtherOptions(dynamicOptions)));
+        Map<String, String> wrappedOptions =
+                prepareBranchOptions(dynamicOptions, 
wrapped.coreOptions().branch(), wrapped);
+
+        Map<String, String> otherOptions =
+                prepareBranchOptions(
+                        rewriteOtherOptions(dynamicOptions),
+                        other().coreOptions().branch(),
+                        other());
+
+        return new ChainGroupReadTable(wrapped.copy(wrappedOptions), 
other().copy(otherOptions));
     }
 
     @Override
     public FileStoreTable copy(TableSchema newTableSchema) {
+        Map<String, String> wrappedOptions =
+                prepareBranchOptions(
+                        newTableSchema.options(), 
wrapped.coreOptions().branch(), wrapped);
+
+        Map<String, String> otherOptions =
+                prepareBranchOptions(
+                        rewriteOtherOptions(newTableSchema.options()),
+                        other().coreOptions().branch(),
+                        other());
+
         return new ChainGroupReadTable(
-                wrapped.copy(newTableSchema),
-                
other().copy(newTableSchema.copy(rewriteOtherOptions(newTableSchema.options()))));
+                wrapped.copy(newTableSchema.copy(wrappedOptions)),
+                other().copy(newTableSchema.copy(otherOptions)));
     }
 
     @Override
     public FileStoreTable copyWithoutTimeTravel(Map<String, String> 
dynamicOptions) {
+        Map<String, String> wrappedOptions =
+                prepareBranchOptions(dynamicOptions, 
wrapped.coreOptions().branch(), wrapped);
+
+        Map<String, String> otherOptions =
+                prepareBranchOptions(
+                        rewriteOtherOptions(dynamicOptions),
+                        other().coreOptions().branch(),
+                        other());
+
         return new ChainGroupReadTable(
-                wrapped.copyWithoutTimeTravel(dynamicOptions),
-                
other().copyWithoutTimeTravel(rewriteOtherOptions(dynamicOptions)));
+                wrapped.copyWithoutTimeTravel(wrappedOptions),
+                other().copyWithoutTimeTravel(otherOptions));
     }
 
     @Override
@@ -112,6 +163,30 @@ public class ChainGroupReadTable extends 
FallbackReadFileStoreTable {
                 wrapped.copyWithLatestSchema(), 
other().copyWithLatestSchema());
     }
 
+    /**
+     * Prepares options for a branch table. Starts from the new schema's 
options, but overrides
+     * branch-owned options (like merge-engine, bucket) with the branch's own 
values to preserve
+     * branch-specific configurations.
+     */
+    private static Map<String, String> prepareBranchOptions(
+            Map<String, String> newOptions, String branch, FileStoreTable 
sourceTable) {
+        Map<String, String> result = new HashMap<>(newOptions);
+
+        // Override branch-owned options with sourceTable's values to preserve
+        // branch-specific configurations
+        for (String key : UNSAFE_COPY_OPTIONS) {
+            String sourceValue = sourceTable.schema().options().get(key);
+            if (sourceValue != null) {
+                result.put(key, sourceValue);
+            }
+        }
+
+        // Set branch name (each sub-table has its own branch identity)
+        result.put(CoreOptions.BRANCH.key(), branch);
+
+        return result;
+    }
+
     @Override
     public FileStoreTable switchToBranch(String branchName) {
         return new ChainGroupReadTable(switchWrappedToBranch(branchName), 
other());
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
index 276114712a..fcd07cb26b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/ChainTableStreamScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/ChainTableStreamScan.java
@@ -40,6 +40,7 @@ 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.Filter;
 import org.apache.paimon.utils.SnapshotManager;
 
 import org.slf4j.Logger;
@@ -103,13 +104,17 @@ public class ChainTableStreamScan implements 
StreamDataTableScan {
     /** 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()}. */
+    /**
+     * Predicates, shard, and bucket filter for applying to local scans in 
{@link #planStarting()}.
+     */
     private final List<Predicate> predicates = new ArrayList<>();
 
     private int shardIndex = -1;
 
     private int shardCount = -1;
 
+    @Nullable private Filter<Integer> bucketFilter;
+
     /** Maximum number of retries when race condition is detected during 
position capture. */
     private static final int MAX_RACE_RETRIES = 3;
 
@@ -120,6 +125,8 @@ public class ChainTableStreamScan implements 
StreamDataTableScan {
                         chainGroupReadTable.schema(), chainGroupReadTable);
         this.deltaStreamScan = (DataTableStreamScan) 
chainGroupReadTable.other().newStreamScan();
 
+        
ChainTableUtils.validateChainTableForIncrementalRead(chainGroupReadTable);
+
         // Initialize partition projector and chain comparator using the 
established pattern
         // from ChainTableBatchScan.
         List<String> chainKeys =
@@ -223,13 +230,9 @@ public class ChainTableStreamScan implements 
StreamDataTableScan {
         // 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)));
+            FileStoreTable pinnedDelta = 
deltaTable.copy(pinnedOptions(deltaLatestId));
             DataTableScan pinnedDeltaScan = pinnedDelta.newScan();
-            applyPredicatesAndShard(pinnedDeltaScan);
+            applyPredicatesShardAndBucket(pinnedDeltaScan);
             deltaSplitsByPartition = groupByPartition(pinnedDeltaScan);
         } else {
             deltaSplitsByPartition = Collections.emptyMap();
@@ -242,11 +245,7 @@ public class ChainTableStreamScan implements 
StreamDataTableScan {
         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)));
+            pinnedSnapshot = 
chainGroupReadTable.wrapped.copy(pinnedOptions(snapshotLatestId));
             DataTableScan partitionListingScan = pinnedSnapshot.newScan();
             for (BinaryRow partition : partitionListingScan.listPartitions()) {
                 Object groupKey = toGroupKey(partition);
@@ -268,7 +267,7 @@ public class ChainTableStreamScan implements 
StreamDataTableScan {
         if (!latestPartitions.isEmpty() && pinnedSnapshot != null) {
             DataTableScan snapshotScan = pinnedSnapshot.newScan();
             snapshotScan.withPartitionFilter(latestPartitions);
-            applyPredicatesAndShard(snapshotScan);
+            applyPredicatesShardAndBucket(snapshotScan);
             snapshotSplitsByPartition = groupByPartition(snapshotScan);
         } else {
             snapshotSplitsByPartition = Collections.emptyMap();
@@ -427,17 +426,40 @@ public class ChainTableStreamScan implements 
StreamDataTableScan {
         return this;
     }
 
+    @Override
+    public InnerTableScan withBucketFilter(Filter<Integer> bucketFilter) {
+        this.bucketFilter = bucketFilter;
+        batchScan.withBucketFilter(bucketFilter);
+        deltaStreamScan.withBucketFilter(bucketFilter);
+        return this;
+    }
+
+    /**
+     * Creates options for pinning a branch table to a specific snapshot. Sets 
{@code
+     * scan.snapshot-id} to the given id and {@code scan.mode=from-snapshot} 
to ensure the table
+     * reads from the pinned snapshot rather than using its default scan mode.
+     */
+    private static Map<String, String> pinnedOptions(long snapshotId) {
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.SCAN_SNAPSHOT_ID.key(), 
String.valueOf(snapshotId));
+        options.put(CoreOptions.SCAN_MODE.key(), 
CoreOptions.StartupMode.FROM_SNAPSHOT.toString());
+        return options;
+    }
+
     /**
-     * Applies all previously set predicates and shard to a newly created 
scan. Used for the pinned
-     * delta scan in {@link #planStarting()}.
+     * Applies all previously set predicates, shard, and bucket filter to a 
newly created scan. Used
+     * for the pinned delta scan in {@link #planStarting()}.
      */
-    private void applyPredicatesAndShard(DataTableScan scan) {
+    private void applyPredicatesShardAndBucket(DataTableScan scan) {
         for (Predicate p : predicates) {
             scan.withFilter(p);
         }
         if (shardIndex >= 0) {
             scan.withShard(shardIndex, shardCount);
         }
+        if (bucketFilter != null) {
+            scan.withBucketFilter(bucketFilter);
+        }
     }
 
     @Nullable
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 6b3512c615..d296b099da 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
@@ -129,6 +129,22 @@ public class ChainSplit implements Split {
         return Objects.hash(logicalPartition, dataFiles);
     }
 
+    @Override
+    public String toString() {
+        return "{"
+                + "partition=hash-"
+                + logicalPartition.hashCode()
+                + ", files="
+                + (dataFiles == null ? 0 : dataFiles.size())
+                + ", branches="
+                + fileBranchMapping.size()
+                + ", bucketPaths="
+                + fileBucketPathMapping.size()
+                + '}'
+                + "@"
+                + Integer.toHexString(hashCode());
+    }
+
     private void writeObject(ObjectOutputStream out) throws IOException {
         serialize(new DataOutputViewStreamWrapper(out));
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/ChainTableUtils.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/ChainTableUtils.java
index e6d4e4aadf..d338df5191 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/ChainTableUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/ChainTableUtils.java
@@ -364,4 +364,21 @@ public class ChainTableUtils {
 
         return PredicateBuilder.and(conditions);
     }
+
+    /**
+     * Validates that the chain table configuration is compatible with 
incremental read paths
+     * (streaming read and lookup join). All validation rules for incremental 
reads should be
+     * centralized here.
+     */
+    public static void 
validateChainTableForIncrementalRead(ChainGroupReadTable table) {
+        CoreOptions.MergeEngine mergeEngine = 
table.other().coreOptions().mergeEngine();
+        if (mergeEngine != CoreOptions.MergeEngine.DEDUPLICATE) {
+            throw new IllegalArgumentException(
+                    "Chain table does not support "
+                            + mergeEngine
+                            + " merge engine on the delta branch "
+                            + "for streaming read or lookup join. "
+                            + "Please use the DEDUPLICATE merge engine on the 
delta branch.");
+        }
+    }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FileStoreLookupFunction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FileStoreLookupFunction.java
index 69d834615f..6d49c6df6c 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FileStoreLookupFunction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FileStoreLookupFunction.java
@@ -30,6 +30,8 @@ import org.apache.paimon.flink.utils.RuntimeContextUtils;
 import org.apache.paimon.flink.utils.TableScanUtils;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.table.ChainGroupReadTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.source.OutOfRangeException;
 import org.apache.paimon.types.RowType;
@@ -196,7 +198,12 @@ public class FileStoreLookupFunction implements 
Serializable, Closeable {
                 joinKeys);
 
         LOG.info("Creating lookup table for {}.", table.name());
-        if (options.get(LOOKUP_CACHE_MODE) == LookupCacheMode.AUTO
+        boolean isChainTable =
+                table instanceof FallbackReadFileStoreTable
+                        && ((FallbackReadFileStoreTable) table).other()
+                                instanceof ChainGroupReadTable;
+        if (!isChainTable
+                && options.get(LOOKUP_CACHE_MODE) == LookupCacheMode.AUTO
                 && new HashSet<>(table.primaryKeys()).equals(new 
HashSet<>(joinKeys))) {
             if (isRemoteServiceAvailable(table)) {
                 this.lookupTable =
@@ -403,7 +410,21 @@ public class FileStoreLookupFunction implements 
Serializable, Closeable {
             return false;
         }
 
-        Long latestSnapshotId = ((FileStoreTable) 
table).snapshotManager().latestSnapshotId();
+        boolean isChainTable =
+                table instanceof FallbackReadFileStoreTable
+                        && ((FallbackReadFileStoreTable) table).other()
+                                instanceof ChainGroupReadTable;
+        // For chain tables, use the delta branch's snapshot manager:
+        //   table.other() = ChainGroupReadTable, .other() = delta branch 
table.
+        Long latestSnapshotId =
+                isChainTable
+                        ? ((FallbackReadFileStoreTable)
+                                        ((FallbackReadFileStoreTable) 
table).other())
+                                .other()
+                                .snapshotManager()
+                                .latestSnapshotId()
+                        : table.snapshotManager().latestSnapshotId();
+
         Long nextSnapshotId = lookupTable.nextSnapshotId();
         if (latestSnapshotId == null || nextSnapshotId == null) {
             return false;
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
index 520021fa4d..66766f518e 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/FullCacheLookupTable.java
@@ -34,6 +34,8 @@ import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.reader.RecordReaderIterator;
 import org.apache.paimon.sort.BinaryExternalSortBuffer;
+import org.apache.paimon.table.ChainGroupReadTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.RowType;
@@ -258,7 +260,27 @@ public abstract class FullCacheLookupTable implements 
LookupTable {
             return;
         }
 
-        Long latestSnapshotId = table.snapshotManager().latestSnapshotId();
+        // For chain tables, the delta branch maintains its own snapshot 
sequence.
+        // Navigate to the delta branch's snapshot manager for backlog 
calculation:
+        //   unwrapped = ChainTableFileStoreTable
+        //     .other() = ChainGroupReadTable
+        //     .other() = delta branch table
+        FileStoreTable unwrapped = table;
+        if (table instanceof LookupFileStoreTable) {
+            unwrapped = ((LookupFileStoreTable) table).wrapped();
+        }
+        boolean isChainTable =
+                unwrapped instanceof FallbackReadFileStoreTable
+                        && ((FallbackReadFileStoreTable) unwrapped).other()
+                                instanceof ChainGroupReadTable;
+        Long latestSnapshotId =
+                isChainTable
+                        ? ((FallbackReadFileStoreTable)
+                                        ((FallbackReadFileStoreTable) 
unwrapped).other())
+                                .other()
+                                .snapshotManager()
+                                .latestSnapshotId()
+                        : table.snapshotManager().latestSnapshotId();
         Long nextSnapshotId = reader.nextSnapshotId();
         if (latestSnapshotId != null
                 && nextSnapshotId != null
@@ -413,7 +435,8 @@ public abstract class FullCacheLookupTable implements 
LookupTable {
         void finish() throws IOException;
     }
 
-    static FullCacheLookupTable create(Context context, long lruCacheSize) {
+    @VisibleForTesting
+    public static FullCacheLookupTable create(Context context, long 
lruCacheSize) {
         List<String> primaryKeys = context.table.primaryKeys();
         if (primaryKeys.isEmpty()) {
             return new NoPrimaryKeyLookupTable(context, lruCacheSize);
@@ -445,7 +468,7 @@ public abstract class FullCacheLookupTable implements 
LookupTable {
                 File tempPath,
                 List<String> joinKey,
                 @Nullable Set<Integer> requiredCachedBucketIds) {
-            this.table = new LookupFileStoreTable(table, joinKey);
+            this.table = LookupFileStoreTable.create(table, joinKey);
             this.projection = projection;
             this.tablePredicate = tablePredicate;
             this.projectedPredicate = projectedPredicate;
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupFileStoreTable.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupFileStoreTable.java
index 353c99d2b1..bde9aa6c02 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupFileStoreTable.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupFileStoreTable.java
@@ -26,10 +26,13 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.options.description.DescribedEnum;
 import org.apache.paimon.options.description.InlineElement;
 import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.ChainGroupReadTable;
 import org.apache.paimon.table.DelegatedFileStoreTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.source.InnerTableRead;
 import org.apache.paimon.table.source.StreamDataTableScan;
+import org.apache.paimon.utils.ChainTableUtils;
 
 import java.util.HashSet;
 import java.util.List;
@@ -46,6 +49,20 @@ public class LookupFileStoreTable extends 
DelegatedFileStoreTable {
 
     private final LookupStreamScanMode lookupScanMode;
 
+    /**
+     * Creates a {@link LookupFileStoreTable} for the given table. For chain 
tables, validates
+     * constraints before returning.
+     */
+    public static LookupFileStoreTable create(FileStoreTable table, 
List<String> joinKeys) {
+        if (table instanceof FallbackReadFileStoreTable
+                && ((FallbackReadFileStoreTable) table).other() instanceof 
ChainGroupReadTable) {
+            ChainGroupReadTable chainGroupReadTable =
+                    (ChainGroupReadTable) ((FallbackReadFileStoreTable) 
table).other();
+            validateChainTableConstraints(table, chainGroupReadTable, 
joinKeys);
+        }
+        return new LookupFileStoreTable(table, joinKeys);
+    }
+
     public LookupFileStoreTable(FileStoreTable wrapped, List<String> joinKeys) 
{
         super(wrapped);
         this.lookupScanMode = lookupStreamScanMode(wrapped, joinKeys);
@@ -58,6 +75,10 @@ public class LookupFileStoreTable extends 
DelegatedFileStoreTable {
 
     @Override
     public InnerTableRead newRead() {
+        if (wrapped instanceof FallbackReadFileStoreTable
+                && ((FallbackReadFileStoreTable) wrapped).other() instanceof 
ChainGroupReadTable) {
+            return wrapped.newRead();
+        }
         switch (lookupScanMode) {
             case CHANGELOG:
             case FILE_MONITOR:
@@ -73,6 +94,10 @@ public class LookupFileStoreTable extends 
DelegatedFileStoreTable {
 
     @Override
     public StreamDataTableScan newStreamScan() {
+        if (wrapped instanceof FallbackReadFileStoreTable
+                && ((FallbackReadFileStoreTable) wrapped).other() instanceof 
ChainGroupReadTable) {
+            return wrapped.newStreamScan();
+        }
         return new LookupDataTableScan(wrapped, wrapped.newSnapshotReader(), 
lookupScanMode);
     }
 
@@ -116,6 +141,34 @@ public class LookupFileStoreTable extends 
DelegatedFileStoreTable {
         }
     }
 
+    private static void validateChainTableConstraints(
+            FileStoreTable table, ChainGroupReadTable chainGroupReadTable, 
List<String> joinKeys) {
+        // Validate merge engine early — before scan creation — for better 
error messages.
+        // The same validation is also in ChainTableStreamScan constructor as 
a safety net.
+        
ChainTableUtils.validateChainTableForIncrementalRead(chainGroupReadTable);
+
+        List<String> partitionKeys = table.schema().partitionKeys();
+        for (String joinKey : joinKeys) {
+            if (partitionKeys.contains(joinKey)) {
+                throw new UnsupportedOperationException(
+                        "Chain table lookup join does not support partition 
keys in join condition. "
+                                + "Partition keys: "
+                                + partitionKeys
+                                + ", join keys: "
+                                + joinKeys);
+            }
+        }
+        Options options = Options.fromMap(table.options());
+        FlinkConnectorOptions.LookupCacheMode cacheMode = 
options.get(LOOKUP_CACHE_MODE);
+        if (cacheMode != FlinkConnectorOptions.LookupCacheMode.AUTO
+                && cacheMode != FlinkConnectorOptions.LookupCacheMode.FULL) {
+            throw new UnsupportedOperationException(
+                    "Chain table lookup join does not support cache mode "
+                            + cacheMode
+                            + ". Please use AUTO or FULL.");
+        }
+    }
+
     /** Inner stream scan mode for lookup table. */
     public enum LookupStreamScanMode implements DescribedEnum {
         CHANGELOG("changelog", "Streaming reading based on changelog or delta 
data files."),
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupStreamingReader.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupStreamingReader.java
index df326054b1..1c2f98fd82 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupStreamingReader.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/LookupStreamingReader.java
@@ -27,10 +27,13 @@ import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.reader.ReaderSupplier;
 import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.ChainTableStreamScan;
+import org.apache.paimon.table.source.ChainSplit;
 import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.IncrementalSplit;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.StreamDataTableScan;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.Filter;
 import org.apache.paimon.utils.FunctionWithIOException;
@@ -51,6 +54,7 @@ import java.util.stream.IntStream;
 
 import static 
org.apache.paimon.flink.FlinkConnectorOptions.LOOKUP_BOOTSTRAP_PARALLELISM;
 import static 
org.apache.paimon.predicate.PredicateBuilder.transformFieldMapping;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
 
 /** A streaming reader to load data into {@link LookupTable}. */
 public class LookupStreamingReader {
@@ -62,7 +66,7 @@ public class LookupStreamingReader {
     @Nullable private final Filter<InternalRow> cacheRowFilter;
     private final ReadBuilder readBuilder;
     @Nullable private final Predicate projectedPredicate;
-    private final LookupDataTableScan scan;
+    private final StreamDataTableScan scan;
 
     public LookupStreamingReader(
             LookupFileStoreTable table,
@@ -83,8 +87,17 @@ public class LookupStreamingReader {
                                 requireCachedBucketIds == null
                                         ? null
                                         : requireCachedBucketIds::contains);
-        scan = (LookupDataTableScan) readBuilder.newStreamScan();
-        scan.setScanPartitions(scanPartitions);
+        scan = (StreamDataTableScan) readBuilder.newStreamScan();
+        checkArgument(
+                scan instanceof LookupDataTableScan || scan instanceof 
ChainTableStreamScan,
+                "Unexpected scan type for lookup: %s",
+                scan.getClass().getName());
+        if (scan instanceof LookupDataTableScan) {
+            ((LookupDataTableScan) scan).setScanPartitions(scanPartitions);
+        }
+        // Note: ChainTableStreamScan does not support partition filters via 
setScanPartitions.
+        // Chain tables handle partitions through their own chain-merge logic 
across snapshot
+        // and delta branches, so partition-level filtering is not applicable 
in the same way.
 
         if (predicate != null) {
             List<String> fieldNames = table.rowType().getFieldNames();
@@ -157,14 +170,21 @@ public class LookupStreamingReader {
         }
 
         Split split = splits.get(0);
-        long snapshotId =
-                split instanceof DataSplit
-                        ? ((DataSplit) split).snapshotId()
-                        : ((IncrementalSplit) split).snapshotId();
+        String splitInfo;
+        if (split instanceof DataSplit) {
+            splitInfo = "snapshotId=" + ((DataSplit) split).snapshotId();
+        } else if (split instanceof IncrementalSplit) {
+            splitInfo = "snapshotId=" + ((IncrementalSplit) 
split).snapshotId();
+        } else if (split instanceof ChainSplit) {
+            splitInfo = split.toString();
+        } else {
+            splitInfo = split.getClass().getSimpleName();
+        }
         LOG.info(
-                "LookupStreamingReader get splits from {} with snapshotId {}.",
+                "LookupStreamingReader get {} splits from {} ({}).",
+                splits.size(),
                 table.name(),
-                snapshotId);
+                splitInfo);
     }
 
     @Nullable
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/PrimaryKeyPartialLookupTable.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/PrimaryKeyPartialLookupTable.java
index b00ac7e083..9964a8fa6c 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/PrimaryKeyPartialLookupTable.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/lookup/PrimaryKeyPartialLookupTable.java
@@ -220,7 +220,7 @@ public class PrimaryKeyPartialLookupTable implements 
LookupTable {
         return new PrimaryKeyPartialLookupTable(
                 (filter, cacheRowFilter) ->
                         new LocalQueryExecutor(
-                                new LookupFileStoreTable(table, joinKey),
+                                LookupFileStoreTable.create(table, joinKey),
                                 projection,
                                 tempPath,
                                 filter,
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 7d7a053a16..6a9b2c8058 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
@@ -21,35 +21,50 @@ 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.lookup.FullCacheLookupTable;
+import org.apache.paimon.flink.lookup.LookupFileStoreTable;
 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.ChainTableFileStoreTable;
 import org.apache.paimon.table.ChainTableStreamScan;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.ChainSplit;
+import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.DataTableScan;
+import org.apache.paimon.table.source.Split;
 import org.apache.paimon.table.source.TableScan;
 import org.apache.paimon.utils.BlockingIterator;
 
+import org.apache.flink.api.common.JobStatus;
 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.EnvironmentSettings;
 import org.apache.flink.table.api.TableResult;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.table.api.config.ExecutionConfigOptions;
 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 org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
+import java.io.File;
 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.CompletableFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 
@@ -59,6 +74,11 @@ import static 
org.assertj.core.api.Assertions.assertThatThrownBy;
 /** IT cases for chain table using Flink SQL. */
 public class FlinkChainTableITCase extends CatalogITCaseBase {
 
+    @SuppressWarnings("unused")
+    static boolean isFlink2OrLater() {
+        return isFlinkVersionGreaterThanOrEqualTo("2.0");
+    }
+
     private List<String> collectResult(String query) throws Exception {
         List<String> result = new ArrayList<>();
         try (CloseableIterator<Row> it = tEnv.executeSql(query).collect()) {
@@ -2301,8 +2321,6 @@ public class FlinkChainTableITCase extends 
CatalogITCaseBase {
                         + " 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")
@@ -2378,8 +2396,6 @@ public class FlinkChainTableITCase extends 
CatalogITCaseBase {
                         + " 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")
@@ -2387,4 +2403,1403 @@ public class FlinkChainTableITCase extends 
CatalogITCaseBase {
 
         it.close();
     }
+
+    @Test
+    public void testLookupJoin() throws Exception {
+        // Create chain table as dimension table
+        sql(
+                "CREATE TABLE chain_dim ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim");
+
+        // Write snapshot branch
+        sql(
+                "INSERT OVERWRITE `chain_dim$branch_snapshot` PARTITION (dt = 
'20250808')"
+                        + " VALUES (1, 1, 'snap_1'), (2, 1, 'snap_2'), (3, 1, 
'snap_3')");
+
+        // Write delta branch (new partition, no anchor in snapshot)
+        sql(
+                "INSERT OVERWRITE `chain_dim$branch_delta` PARTITION (dt = 
'20250809')"
+                        + " VALUES (2, 2, 'delta_2_updated'), (4, 1, 
'delta_4')");
+
+        // Create source table as Paimon table
+        sql(
+                "CREATE TABLE source_t ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+
+        // Insert source data
+        sql("INSERT INTO source_t VALUES (1), (2), (3), (4)");
+
+        // Lookup join on k (non-partition key)
+        List<String> result =
+                collectResult(
+                        "SELECT S.id, D.k, D.v "
+                                + "FROM source_t AS S "
+                                + "LEFT JOIN chain_dim /*+ 
OPTIONS('lookup.cache' = 'full') */ "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k");
+
+        // Verify lookup results.
+        // Source has 4 rows (id=1,2,3,4). Lightweight Phase 1 dim data:
+        //   snapshot@20250808: k=1(snap_1), k=2(snap_2), k=3(snap_3)
+        //   delta@20250809 (no anchor merge): k=2(delta_2_updated), 
k=4(delta_4)
+        // k=1 matches id=1 (snapshot only), k=2 matches id=2 (snapshot + 
delta = 2 rows),
+        // k=3 matches id=3 (snapshot only), k=4 matches id=4 (delta only) → 5 
lookup matches.
+        assertThat(result).hasSize(5);
+        assertThat(result)
+                .anyMatch(r -> r.contains("snap_1"))
+                .anyMatch(r -> r.contains("snap_2"))
+                .anyMatch(r -> r.contains("delta_2_updated"))
+                .anyMatch(r -> r.contains("snap_3"))
+                .anyMatch(r -> r.contains("delta_4"));
+    }
+
+    @Test
+    @EnabledIf(
+            value = "isFlink2OrLater",
+            disabledReason = "Custom shuffle lookup join requires Flink 2.x.")
+    public void testLookupJoinWithBucketShuffle() throws Exception {
+        sql(
+                "CREATE TABLE chain_dim_shuffle ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_shuffle");
+
+        // Write snapshot branch
+        sql(
+                "INSERT OVERWRITE `chain_dim_shuffle$branch_snapshot` 
PARTITION (dt = '20250808')"
+                        + " VALUES (1, 1, 'snap_1'), (2, 1, 'snap_2'), (3, 1, 
'snap_3')");
+
+        // Write delta branch (new partition, no anchor in snapshot)
+        sql(
+                "INSERT OVERWRITE `chain_dim_shuffle$branch_delta` PARTITION 
(dt = '20250809')"
+                        + " VALUES (2, 2, 'delta_2_updated'), (4, 1, 
'delta_4')");
+
+        // Create source table
+        sql(
+                "CREATE TABLE source_shuffle ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_shuffle VALUES (1), (2), (3), (4)");
+
+        String query =
+                "SELECT /*+ LOOKUP('table'='D', 'shuffle'='true') */ S.id, 
D.k, D.v "
+                        + "FROM source_shuffle AS S "
+                        + "JOIN chain_dim_shuffle "
+                        + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                        + "ON S.id = D.k";
+
+        // Verify the execution plan actually uses bucket shuffle partitioning.
+        // "shuffle=[true]" in the LookupJoin node proves the planner 
recognized the LOOKUP hint
+        // and will use the BucketShufflePartitioner provided by Paimon's 
BaseDataTableSource.
+        
sEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_RESOURCE_DEFAULT_PARALLELISM,
 2);
+        String explainPlan = sEnv.explainSql(query);
+        assertThat(explainPlan)
+                .as("EXPLAIN plan should contain shuffle=[true] proving bucket 
shuffle is active")
+                .contains("shuffle=[true]");
+
+        // Expected lightweight Phase 1 data (no anchor merge):
+        //   snapshot@20250808: k=1(snap_1), k=2(snap_2), k=3(snap_3)
+        //   delta@20250809 (no anchor merge): k=2(delta_2_updated), 
k=4(delta_4)
+        // Source has 4 rows (id=1,2,3,4). k=1 matches id=1 (snapshot only),
+        // k=2 matches id=2 (snapshot + delta = 2 rows), k=3 matches id=3 
(snapshot only),
+        // k=4 matches id=4 (delta only) → 5 lookup matches.
+        List<Row> result = streamSqlBlockIter(query).collect(5);
+
+        assertThat(result).hasSize(5);
+        assertThat(result)
+                .anyMatch(r -> r.toString().contains("snap_1"))
+                .anyMatch(r -> r.toString().contains("snap_2"))
+                .anyMatch(r -> r.toString().contains("delta_2_updated"))
+                .anyMatch(r -> r.toString().contains("snap_3"))
+                .anyMatch(r -> r.toString().contains("delta_4"));
+    }
+
+    @Test
+    public void testLookupJoinDeltaOnly() throws Exception {
+        // Chain table with only delta data (no snapshot)
+        sql(
+                "CREATE TABLE chain_dim_delta_only ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_delta_only");
+
+        // Write only delta (no snapshot)
+        sql(
+                "INSERT OVERWRITE `chain_dim_delta_only$branch_delta` 
PARTITION (dt = '20250808')"
+                        + " VALUES (1, 1, 'v1'), (2, 1, 'v2'), (3, 1, 'v3')");
+
+        // Verify batch read works first
+        List<String> batchResult =
+                collectResult("SELECT k, v FROM chain_dim_delta_only WHERE dt 
= '20250808'");
+        assertThat(batchResult).as("Batch read of delta-only chain 
table").hasSize(3);
+
+        sql(
+                "CREATE TABLE source_delta_only ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_delta_only VALUES (1), (2), (3)");
+
+        List<String> result =
+                collectResult(
+                        "SELECT S.id, D.k, D.v "
+                                + "FROM source_delta_only AS S "
+                                + "LEFT JOIN chain_dim_delta_only "
+                                + "/*+ OPTIONS('lookup.cache' = 'full') */ "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k");
+
+        assertThat(result).hasSize(3);
+        assertThat(result)
+                .containsExactlyInAnyOrder("+I[1, 1, v1]", "+I[2, 2, v2]", 
"+I[3, 3, v3]");
+    }
+
+    @Test
+    public void testLookupJoinSnapshotOnly() throws Exception {
+        // Chain table with only snapshot data (no delta)
+        sql(
+                "CREATE TABLE chain_dim_snap_only ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_snap_only");
+
+        // Write only snapshot (no delta)
+        sql(
+                "INSERT OVERWRITE `chain_dim_snap_only$branch_snapshot` "
+                        + "PARTITION (dt = '20250808')"
+                        + " VALUES (1, 1, 's1'), (2, 1, 's2')");
+
+        sql(
+                "CREATE TABLE source_snap_only ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_snap_only VALUES (1), (2)");
+
+        List<String> result =
+                collectResult(
+                        "SELECT S.id, D.k, D.v "
+                                + "FROM source_snap_only AS S "
+                                + "LEFT JOIN chain_dim_snap_only "
+                                + "/*+ OPTIONS('lookup.cache' = 'full') */ "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k");
+
+        assertThat(result).hasSize(2);
+        assertThat(result).containsExactlyInAnyOrder("+I[1, 1, s1]", "+I[2, 2, 
s2]");
+    }
+
+    @Test
+    public void testLookupJoinRejectsPartitionKeyInJoinCondition() throws 
Exception {
+        sql(
+                "CREATE TABLE chain_dim_pk ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_pk");
+
+        sql(
+                "INSERT OVERWRITE `chain_dim_pk$branch_delta` PARTITION (dt = 
'20250808')"
+                        + " VALUES (1, 1, 'v1')");
+
+        sql(
+                "CREATE TABLE source_pk ("
+                        + "  id BIGINT,"
+                        + "  dt STRING,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_pk VALUES (1, '20250808')");
+
+        // Join on partition key dt should fail
+        assertThatThrownBy(
+                        () ->
+                                collectResult(
+                                        "SELECT S.id, D.k "
+                                                + "FROM source_pk AS S "
+                                                + "LEFT JOIN chain_dim_pk "
+                                                + "/*+ OPTIONS('lookup.cache' 
= 'full') */ "
+                                                + "FOR SYSTEM_TIME AS OF 
S.proc_time AS D "
+                                                + "ON S.dt = D.dt"))
+                .rootCause()
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("partition keys");
+    }
+
+    @Test
+    public void testLookupJoinRejectsInvalidCacheMode() throws Exception {
+        sql(
+                "CREATE TABLE chain_dim_mode ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_mode");
+
+        sql(
+                "INSERT OVERWRITE `chain_dim_mode$branch_delta` PARTITION (dt 
= '20250808')"
+                        + " VALUES (1, 1, 'v1')");
+
+        sql(
+                "CREATE TABLE source_mode ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_mode VALUES (1)");
+
+        // cache-mode=memory is not supported for chain tables
+        assertThatThrownBy(
+                        () ->
+                                collectResult(
+                                        "SELECT S.id, D.k "
+                                                + "FROM source_mode AS S "
+                                                + "LEFT JOIN chain_dim_mode "
+                                                + "/*+ OPTIONS('lookup.cache' 
= 'memory') */ "
+                                                + "FOR SYSTEM_TIME AS OF 
S.proc_time AS D "
+                                                + "ON S.id = D.k"))
+                .rootCause()
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("cache mode");
+    }
+
+    @Test
+    public void testLookupJoinRejectsJoinKeyContainingPartitionKey() throws 
Exception {
+        // PK = (dt, k). If join keys = (dt, k) = PK, this triggers the AUTO + 
PK==JK path
+        // in FileStoreLookupFunction.open(). The chain table validation 
should reject it
+        // because dt is a partition key.
+        sql(
+                "CREATE TABLE chain_dim_pkjk ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_pkjk");
+
+        sql(
+                "INSERT OVERWRITE `chain_dim_pkjk$branch_delta` PARTITION (dt 
= '20250808')"
+                        + " VALUES (1, 1, 'v1')");
+
+        sql(
+                "CREATE TABLE source_pkjk ("
+                        + "  id BIGINT,"
+                        + "  dt STRING,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_pkjk VALUES (1, '20250808')");
+
+        // Join on both dt and k (= full PK) should fail because dt is a 
partition key
+        assertThatThrownBy(
+                        () ->
+                                collectResult(
+                                        "SELECT S.id, D.v "
+                                                + "FROM source_pkjk AS S "
+                                                + "LEFT JOIN chain_dim_pkjk "
+                                                + "FOR SYSTEM_TIME AS OF 
S.proc_time AS D "
+                                                + "ON S.dt = D.dt AND S.id = 
D.k"))
+                .rootCause()
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("partition keys");
+    }
+
+    @Test
+    public void testLookupJoinWithAutoCacheMode() throws Exception {
+        // Same as testLookupJoinDeltaOnly but without explicit 'lookup.cache' 
= 'full' hint.
+        // Default cache mode is AUTO. LookupFileStoreTable.create() validates 
chain table
+        // constraints and FileStoreLookupFunction excludes chain tables from 
the AUTO path,
+        // preventing PrimaryKeyPartialLookupTable from being used.
+        sql(
+                "CREATE TABLE chain_dim_auto ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_auto");
+
+        sql(
+                "INSERT OVERWRITE `chain_dim_auto$branch_delta` PARTITION (dt 
= '20250808')"
+                        + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+        sql(
+                "CREATE TABLE source_auto ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_auto VALUES (1), (2)");
+
+        // No explicit lookup.cache hint — defaults to AUTO, should work via 
AUTO→FULL conversion
+        List<String> result =
+                collectResult(
+                        "SELECT S.id, D.k, D.v "
+                                + "FROM source_auto AS S "
+                                + "LEFT JOIN chain_dim_auto "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k");
+
+        assertThat(result).hasSize(2);
+        assertThat(result).containsExactlyInAnyOrder("+I[1, 1, v1]", "+I[2, 2, 
v2]");
+    }
+
+    @Test
+    public void testChainTableStreamScanIncrementalRefresh() throws Exception {
+        // Tests the ChainTableStreamScan directly at the API level.
+        // Verifies: first plan() = bootstrap (chain-merged ChainSplits),
+        //           second plan() = incremental (delta DataSplits with new 
data).
+        sql(
+                "CREATE TABLE chain_dim_incr ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_incr");
+
+        // Write initial delta data
+        sql(
+                "INSERT OVERWRITE `chain_dim_incr$branch_delta` PARTITION (dt 
= '20250808')"
+                        + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+        // Get the chain table and create a LookupFileStoreTable with 
ChainTableStreamScan
+        FileStoreTable table = (FileStoreTable) paimonTable("chain_dim_incr");
+        LookupFileStoreTable lookupTable = LookupFileStoreTable.create(table, 
Arrays.asList("k"));
+
+        ChainTableStreamScan scan = (ChainTableStreamScan) 
lookupTable.newStreamScan();
+
+        // First plan() = bootstrap: should return ChainSplits with initial 
data
+        List<Split> bootstrapSplits = scan.plan().splits();
+        assertThat(bootstrapSplits).isNotEmpty();
+        assertThat(bootstrapSplits.get(0))
+                .as("Bootstrap should produce ChainSplits")
+                .isInstanceOf(ChainSplit.class);
+
+        // Second plan() = incremental: should be empty (no new delta data)
+        List<Split> emptySplits = scan.plan().splits();
+        assertThat(emptySplits).as("No new data, should be empty").isEmpty();
+
+        // Write new delta data
+        sql(
+                "INSERT INTO `chain_dim_incr$branch_delta` PARTITION (dt = 
'20250809')"
+                        + " VALUES (3, 1, 'v3')");
+
+        // Third plan() = incremental: should return DataSplits with new data
+        List<Split> incrementalSplits = scan.plan().splits();
+        assertThat(incrementalSplits)
+                .as("Should have incremental splits after new delta data")
+                .isNotEmpty();
+        assertThat(incrementalSplits.get(0))
+                .as("Incremental should produce DataSplits")
+                .isInstanceOf(DataSplit.class);
+    }
+
+    @Test
+    public void testLookupScanCheckpointRestore() throws Exception {
+        // Tests checkpoint/restore behavior of ChainTableStreamScan at the 
API level.
+        // Verifies:
+        //   1. Before bootstrap, checkpoint() returns null.
+        //   2. After bootstrap, checkpoint() returns the delta position.
+        //   3. restore(id) sets bootstrapDone=true and positions delta scan 
correctly.
+        //   4. After restore, plan() only returns NEW data (not 
already-consumed data).
+        sql(
+                "CREATE TABLE chain_dim_ckp ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_ckp");
+
+        // Write initial delta data
+        sql(
+                "INSERT OVERWRITE `chain_dim_ckp$branch_delta` PARTITION (dt = 
'20250808')"
+                        + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+        FileStoreTable table = (FileStoreTable) paimonTable("chain_dim_ckp");
+        LookupFileStoreTable lookupTable = LookupFileStoreTable.create(table, 
Arrays.asList("k"));
+
+        ChainTableStreamScan scan = (ChainTableStreamScan) 
lookupTable.newStreamScan();
+
+        // Before bootstrap, checkpoint should be null
+        assertThat(scan.checkpoint()).as("Before bootstrap, checkpoint should 
be null").isNull();
+
+        // Bootstrap
+        List<Split> bootstrapSplits = scan.plan().splits();
+        assertThat(bootstrapSplits).isNotEmpty();
+
+        // After bootstrap, checkpoint should capture the delta position
+        Long checkpointId = scan.checkpoint();
+        assertThat(checkpointId)
+                .as("After bootstrap, checkpoint should capture delta 
position")
+                .isNotNull();
+
+        // No new data, plan() should be empty
+        assertThat(scan.plan().splits()).isEmpty();
+
+        // Write new delta data
+        sql(
+                "INSERT INTO `chain_dim_ckp$branch_delta` PARTITION (dt = 
'20250809')"
+                        + " VALUES (3, 1, 'v3')");
+
+        // Incremental plan() should return new data
+        List<Split> incrSplits1 = scan.plan().splits();
+        assertThat(incrSplits1).isNotEmpty();
+
+        // Checkpoint again
+        Long checkpointId2 = scan.checkpoint();
+        assertThat(checkpointId2).isNotNull();
+        assertThat(checkpointId2)
+                .as("Second checkpoint should be after the first")
+                .isGreaterThan(checkpointId);
+
+        // Simulate restore from first checkpoint
+        ChainTableStreamScan restoredScan = (ChainTableStreamScan) 
lookupTable.newStreamScan();
+        restoredScan.restore(checkpointId);
+
+        // After restore, plan() should return data from checkpointId onwards
+        // (i.e., the data at dt=20250809 that was written after the first 
checkpoint)
+        List<Split> restoredSplits = restoredScan.plan().splits();
+        assertThat(restoredSplits)
+                .as("Restored scan should return data after checkpoint 
position")
+                .isNotEmpty();
+
+        // A fresh scan (no restore) should bootstrap and then be empty
+        ChainTableStreamScan freshScan = (ChainTableStreamScan) 
lookupTable.newStreamScan();
+        freshScan.plan(); // bootstrap
+        assertThat(freshScan.plan().splits())
+                .as("Fresh scan after bootstrap should have no incremental 
data")
+                .isEmpty();
+    }
+
+    @Test
+    public void testLookupJoinWithPredicatePushdown() throws Exception {
+        // Tests that a WHERE condition on the dimension table is correctly 
pushed down
+        // and produces correct lookup results with chain-merged data.
+        sql(
+                "CREATE TABLE chain_dim_pred ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_pred");
+
+        // Write snapshot branch with 3 rows
+        sql(
+                "INSERT OVERWRITE `chain_dim_pred$branch_snapshot` PARTITION 
(dt = '20250808')"
+                        + " VALUES (1, 1, 'snap_1'), (2, 1, 'snap_2'), (3, 1, 
'snap_3')");
+
+        // Write delta branch with updated row k=2 and new row k=4
+        sql(
+                "INSERT OVERWRITE `chain_dim_pred$branch_delta` PARTITION (dt 
= '20250809')"
+                        + " VALUES (2, 2, 'delta_2_updated'), (4, 1, 
'delta_4')");
+
+        sql(
+                "CREATE TABLE source_pred ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_pred VALUES (1), (2), (3), (4)");
+
+        // Lookup join with a predicate on the dimension table's v column.
+        // The predicate v LIKE '%updated%' should be pushed down to the chain 
table scan.
+        // Chain-merged data: k=1(snap_1), k=2(delta_2_updated), k=3(snap_3), 
k=4(delta_4).
+        // Only k=2 has v containing 'updated'.
+        List<String> result =
+                collectResult(
+                        "SELECT S.id, D.k, D.v "
+                                + "FROM source_pred AS S "
+                                + "LEFT JOIN chain_dim_pred "
+                                + "/*+ OPTIONS('lookup.cache' = 'full') */ "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k "
+                                + "WHERE D.v LIKE '%updated%'");
+
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0)).contains("delta_2_updated");
+    }
+
+    @Test
+    public void testLookupRejectsIncompatibleDeltaBranchConfig() throws 
Exception {
+        // Tests that creating a lookup table for a chain table with 
partial-update merge engine
+        // on the delta branch is rejected. The SQL lookup join path is 
covered by
+        // testLookupRejectsAggregateOnDeltaBranch, which verifies the same 
validation
+        // through the SQL path (prepareBranchOptions preserves 
branch-specific merge-engine
+        // during table.copy(TableSchema)).
+        sql(
+                "CREATE TABLE chain_dim_partial_cfg ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_partial_cfg");
+
+        // Alter delta branch to partial-update (unsupported for incremental 
lookup).
+        // The incremental read path (createNoMergeReader) does not apply the 
merge engine,
+        // which would cause partial rows to overwrite complete cached data.
+        sql(
+                "ALTER TABLE `chain_dim_partial_cfg$branch_delta` SET ("
+                        + "  'merge-engine' = 'partial-update'"
+                        + ")");
+
+        // Load the chain table directly from catalog (preserves branch 
on-disk options).
+        // Table loading succeeds because batch reads work fine with 
PARTIAL_UPDATE.
+        FileStoreTable table = (FileStoreTable) 
paimonTable("chain_dim_partial_cfg");
+
+        // Creating a lookup table should fail because the incremental read 
path
+        // (ChainTableStreamScan) does not support PARTIAL_UPDATE on the delta 
branch.
+        assertThatThrownBy(
+                        () ->
+                                
org.apache.paimon.flink.lookup.LookupFileStoreTable.create(
+                                        table, Collections.singletonList("k")))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("merge engine");
+    }
+
+    @Test
+    public void testStreamingReadRejectsPartialUpdateOnDeltaBranch() throws 
Exception {
+        // Tests that creating a streaming scan for a chain table with 
partial-update merge engine
+        // on the delta branch is rejected.
+        sql(
+                "CREATE TABLE chain_stream_partial ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_stream_partial");
+
+        sql(
+                "ALTER TABLE `chain_stream_partial$branch_delta` SET ("
+                        + "  'merge-engine' = 'partial-update'"
+                        + ")");
+
+        FileStoreTable table = (FileStoreTable) 
paimonTable("chain_stream_partial");
+
+        // Creating a streaming scan should fail because ChainTableStreamScan 
does not support
+        // PARTIAL_UPDATE on the delta branch.
+        assertThatThrownBy(() -> table.newStreamScan())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("merge engine");
+    }
+
+    @Test
+    public void testStreamingReadRejectsAggregateOnDeltaBranch() throws 
Exception {
+        // Tests that creating a streaming scan for a chain table with 
aggregation merge engine
+        // on the delta branch is rejected.
+        sql(
+                "CREATE TABLE chain_stream_agg ("
+                        + "  k BIGINT,"
+                        + "  seq BIGINT,"
+                        + "  v INT,"
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_stream_agg");
+
+        sql(
+                "ALTER TABLE `chain_stream_agg$branch_delta` SET ("
+                        + "  'merge-engine' = 'aggregation',"
+                        + "  'fields.v.aggregate-function' = 'sum'"
+                        + ")");
+
+        FileStoreTable table = (FileStoreTable) 
paimonTable("chain_stream_agg");
+
+        // Creating a streaming scan should fail because ChainTableStreamScan 
does not support
+        // AGGREGATE on the delta branch.
+        assertThatThrownBy(() -> table.newStreamScan())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("merge engine");
+    }
+
+    @Test
+    public void testLookupRejectsAggregateOnDeltaBranch() throws Exception {
+        // Tests that creating a lookup table for a chain table with 
aggregation merge engine
+        // on the delta branch is rejected.
+        sql(
+                "CREATE TABLE chain_dim_agg_cfg ("
+                        + "  k BIGINT,"
+                        + "  seq BIGINT,"
+                        + "  v INT,"
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_agg_cfg");
+
+        sql(
+                "ALTER TABLE `chain_dim_agg_cfg$branch_delta` SET ("
+                        + "  'merge-engine' = 'aggregation',"
+                        + "  'fields.v.aggregate-function' = 'sum'"
+                        + ")");
+
+        FileStoreTable table = (FileStoreTable) 
paimonTable("chain_dim_agg_cfg");
+
+        // Creating a lookup table should fail because ChainTableStreamScan 
does not support
+        // AGGREGATE on the delta branch.
+        assertThatThrownBy(
+                        () ->
+                                
org.apache.paimon.flink.lookup.LookupFileStoreTable.create(
+                                        table, Collections.singletonList("k")))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("merge engine");
+
+        // Also verify through SQL lookup join path
+        sql(
+                "INSERT OVERWRITE `chain_dim_agg_cfg$branch_snapshot` 
PARTITION (dt = '20250808')"
+                        + " VALUES (1, 1, 10)");
+        sql(
+                "INSERT OVERWRITE `chain_dim_agg_cfg$branch_delta` PARTITION 
(dt = '20250809')"
+                        + " VALUES (2, 2, 20)");
+
+        sql(
+                "CREATE TABLE source_agg_cfg ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_agg_cfg VALUES (1), (2)");
+
+        String query =
+                "SELECT S.id, D.k, D.v "
+                        + "FROM source_agg_cfg AS S "
+                        + "LEFT JOIN chain_dim_agg_cfg "
+                        + "/*+ OPTIONS('lookup.cache' = 'full') */ "
+                        + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                        + "ON S.id = D.k";
+
+        assertThatThrownBy(() -> collectResult(query))
+                .rootCause()
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("merge engine");
+    }
+
+    /**
+     * Tests that chain table lookup join rejects unsupported scan modes and 
consumer-id. The
+     * validation is inherited from {@link 
ChainTableFileStoreTable#newStreamScan()}.
+     */
+    @Test
+    public void testLookupRejectsUnsupportedScanMode() throws Exception {
+        sql(
+                "CREATE TABLE chain_dim_scan_mode ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_dim_scan_mode");
+
+        FileStoreTable table = paimonTable("chain_dim_scan_mode");
+
+        // scan.mode=latest should be rejected for chain table lookup
+        FileStoreTable tableLatest = 
table.copy(Collections.singletonMap("scan.mode", "latest"));
+        assertThatThrownBy(
+                        () ->
+                                
org.apache.paimon.flink.lookup.LookupFileStoreTable.create(
+                                                tableLatest, 
Collections.singletonList("k"))
+                                        .newStreamScan())
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("scan.mode=latest")
+                .hasMessageContaining("Chain table streaming read does not 
support");
+
+        // consumer-id should be rejected for chain table lookup
+        FileStoreTable tableWithConsumer =
+                table.copy(Collections.singletonMap("consumer-id", 
"my-consumer"));
+        assertThatThrownBy(
+                        () ->
+                                
org.apache.paimon.flink.lookup.LookupFileStoreTable.create(
+                                                tableWithConsumer, 
Collections.singletonList("k"))
+                                        .newStreamScan())
+                .isInstanceOf(UnsupportedOperationException.class)
+                .hasMessageContaining("consumer mode")
+                .hasMessageContaining("consumer-id='my-consumer'");
+    }
+
+    @Test
+    @Timeout(180)
+    public void testLookupJoinWithCompactDeltaMonitorMode() throws Exception {
+        // Main table uses partial-update + force-lookup so that
+        // supportCompactDiffStreamingReading returns true and lookupScanMode 
becomes
+        // COMPACT_DELTA_MONITOR.
+        sql(
+                "CREATE TABLE chain_dim_cdm ("
+                        + "  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' = 'partial-update',"
+                        + "  'force-lookup' = 'true',"
+                        + "  'chain-table.enabled' = 'true',"
+                        + "  'partition.timestamp-pattern' = '$dt',"
+                        + "  'partition.timestamp-formatter' = 'yyyyMMdd'"
+                        + ")");
+        setupChainTableBranches("chain_dim_cdm");
+
+        // Delta branch must be deduplicate for chain table incremental read.
+        sql("ALTER TABLE `chain_dim_cdm$branch_delta` SET ('merge-engine' = 
'deduplicate')");
+
+        // Write data to both branches.
+        sql(
+                "INSERT OVERWRITE `chain_dim_cdm$branch_snapshot` PARTITION 
(dt = '20250808')"
+                        + " VALUES (1, 1, 'snap_v1'), (2, 2, 'snap_v2')");
+        sql(
+                "INSERT INTO `chain_dim_cdm$branch_delta` PARTITION (dt = 
'20250809')"
+                        + " VALUES (1, 3, 'delta_v1'), (3, 4, 'delta_v3')");
+
+        // Verify that LookupFileStoreTable.newRead() delegates to 
wrapped.newRead()
+        // (not LookupCompactDiffRead, which would cause ClassCastException on 
ChainSplit).
+        FileStoreTable table = paimonTable("chain_dim_cdm");
+        org.apache.paimon.flink.lookup.LookupFileStoreTable lookupTable =
+                org.apache.paimon.flink.lookup.LookupFileStoreTable.create(
+                        table, Collections.singletonList("k"));
+        
assertThat(lookupTable.newRead()).isInstanceOf(table.newRead().getClass());
+
+        // Run the lookup join and verify results.
+        sql(
+                "CREATE TABLE source_cdm ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ('connector' = 'paimon')");
+        sql("INSERT INTO source_cdm VALUES (1), (2), (3)");
+
+        List<String> results =
+                collectResult(
+                        "SELECT S.id, D.k, D.v "
+                                + "FROM source_cdm AS S "
+                                + "LEFT JOIN chain_dim_cdm "
+                                + "/*+ OPTIONS('lookup.cache' = 'full') */ "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k");
+
+        // k=1: appears in both snapshot (dt=20250808) and delta (dt=20250809) 
partitions.
+        // Chain table merges at partition level, not row level, so both rows 
appear.
+        // k=2: only in snapshot partition.
+        // k=3: only in delta partition.
+        assertThat(results)
+                .containsExactlyInAnyOrder(
+                        "+I[1, 1, snap_v1]",
+                        "+I[1, 1, delta_v1]",
+                        "+I[2, 2, snap_v2]",
+                        "+I[3, 3, delta_v3]");
+    }
+
+    /**
+     * Verifies that ChainTableStreamScan correctly propagates bucket filters 
to its internal scans.
+     */
+    @Test
+    public void testStreamingReadBucketFilter() throws Exception {
+        sql(
+                "CREATE TABLE chain_bucket_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',"
+                        + "  'chain-table.enabled' = 'true',"
+                        + "  'partition.timestamp-pattern' = '$dt',"
+                        + "  'partition.timestamp-formatter' = 'yyyyMMdd'"
+                        + ")");
+
+        String db = tEnv.getCurrentDatabase();
+        sql("CALL sys.create_branch('%s.chain_bucket_filter', 'snapshot')", 
db);
+        sql("CALL sys.create_branch('%s.chain_bucket_filter', 'delta')", db);
+        for (String tbl :
+                new String[] {
+                    "chain_bucket_filter",
+                    "chain_bucket_filter$branch_snapshot",
+                    "chain_bucket_filter$branch_delta"
+                }) {
+            sql(
+                    "ALTER TABLE `%s` SET ("
+                            + "  'scan.fallback-snapshot-branch' = 'snapshot',"
+                            + "  'scan.fallback-delta-branch' = 'delta')",
+                    tbl);
+        }
+
+        // Write main branch
+        sql(
+                "INSERT OVERWRITE chain_bucket_filter PARTITION (dt = 
'20250810')"
+                        + " VALUES (1, 1, 'v1')");
+
+        // Write delta data across many keys to guarantee both buckets are 
populated
+        for (int i = 1; i <= 50; i++) {
+            sql(
+                    String.format(
+                            "INSERT INTO `chain_bucket_filter$branch_delta`"
+                                    + " PARTITION (dt = '%d') VALUES (%d, 1, 
'v%d')",
+                            20250809 + (i % 5), i, i));
+        }
+
+        FileStoreTable table = paimonTable("chain_bucket_filter");
+
+        // Verify data spans both buckets via delta branch batch scan
+        FileStoreTable deltaTable =
+                (FileStoreTable) 
paimonTable("chain_bucket_filter$branch_delta");
+        java.util.Set<Integer> deltaBuckets = new java.util.HashSet<>();
+        for (Split split : deltaTable.newScan().plan().splits()) {
+            if (split instanceof DataSplit) {
+                deltaBuckets.add(((DataSplit) split).bucket());
+            }
+        }
+        assertThat(deltaBuckets)
+                .as("Test requires data in both buckets")
+                .containsExactlyInAnyOrder(0, 1);
+
+        // Without bucket filter: Phase 1 should return splits from both 
buckets
+        ChainTableStreamScan scanAll = (ChainTableStreamScan) 
table.newStreamScan();
+        TableScan.Plan planAll = scanAll.plan();
+        java.util.Set<Integer> bucketsAll = 
collectBucketsFromChainSplits(planAll);
+        assertThat(bucketsAll)
+                .as("Without filter, should have data from both buckets")
+                .containsExactlyInAnyOrder(0, 1);
+
+        // With bucket filter (only bucket 0): Phase 1 should only return 
bucket 0
+        ChainTableStreamScan scanFiltered = (ChainTableStreamScan) 
table.newStreamScan();
+        scanFiltered.withBucketFilter(b -> b == 0);
+        TableScan.Plan planFiltered = scanFiltered.plan();
+        java.util.Set<Integer> bucketsFiltered = 
collectBucketsFromChainSplits(planFiltered);
+        assertThat(bucketsFiltered)
+                .as("Bucket filter should restrict Phase 1 to bucket 0 only")
+                .containsExactly(0);
+    }
+
+    private java.util.Set<Integer> 
collectBucketsFromChainSplits(TableScan.Plan plan) {
+        java.util.Set<Integer> buckets = new java.util.HashSet<>();
+        for (Split split : plan.splits()) {
+            if (split instanceof ChainSplit) {
+                for (String path : ((ChainSplit) 
split).fileBucketPathMapping().values()) {
+                    if (path.contains("bucket-0")) {
+                        buckets.add(0);
+                    } else if (path.contains("bucket-1")) {
+                        buckets.add(1);
+                    }
+                }
+            }
+        }
+        return buckets;
+    }
+
+    /**
+     * Verifies that lookup join on a chain table branch (e.g., 
t$branch_delta) works correctly.
+     * Branch tables may still carry chain-table.enabled=true, but they are 
not wrapped in
+     * FallbackReadFileStoreTable, so they should be treated as regular tables 
for lookup join.
+     */
+    @Test
+    public void testLookupJoinOnBranchTable() throws Exception {
+        sql(
+                "CREATE TABLE chain_branch_lookup ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches("chain_branch_lookup");
+
+        // Write some data to delta branch
+        sql(
+                "INSERT OVERWRITE `chain_branch_lookup$branch_delta` PARTITION 
(dt = '20250808')"
+                        + " VALUES (1, 1, 'v1'), (2, 1, 'v2')");
+
+        // Create source table
+        sql(
+                "CREATE TABLE source_branch ("
+                        + "  id BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+        sql("INSERT INTO source_branch VALUES (1), (2)");
+
+        // Lookup join on branch table should work correctly
+        String query =
+                "SELECT S.id, D.k, D.v "
+                        + "FROM source_branch AS S "
+                        + "LEFT JOIN `chain_branch_lookup$branch_delta` "
+                        + "/*+ OPTIONS('lookup.cache' = 'full') */ "
+                        + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                        + "ON S.id = D.k";
+
+        List<String> result = collectResult(query);
+        assertThat(result)
+                .as("Lookup join on branch table should return matching rows")
+                .hasSize(2)
+                .containsExactlyInAnyOrder("+I[1, 1, v1]", "+I[2, 2, v2]");
+    }
+
+    /**
+     * Tests that chain table lookup join refresh works correctly for both 
async and non-async
+     * modes. This test verifies the refresh logic in {@code 
FullCacheLookupTable.refresh()} which
+     * has different code paths for async vs non-async refresh. For chain 
tables, the async path
+     * skips the backlog calculation (since outer table and delta branch use 
different snapshot
+     * sequences).
+     *
+     * <p>The lookup join job is started BEFORE inserting source data, so the 
lookup cache is warmed
+     * up before any source data arrives.
+     */
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    @Timeout(120)
+    public void testLookupJoinRefresh(boolean asyncRefresh) throws Exception {
+        String tableName = "chain_refresh_" + (asyncRefresh ? "async" : 
"sync");
+
+        tEnv.useCatalog("PAIMON");
+        tEnv.useDatabase("default");
+
+        sql(
+                "CREATE TABLE "
+                        + tableName
+                        + " ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches(tableName);
+
+        sql(
+                "INSERT INTO `"
+                        + tableName
+                        + "$branch_snapshot` PARTITION (dt = '20250808')"
+                        + " VALUES (1, 1, 'snap_1'), (2, 1, 'snap_2')");
+
+        sql(
+                "INSERT INTO `"
+                        + tableName
+                        + "$branch_delta` PARTITION (dt = '20250809')"
+                        + " VALUES (3, 1, 'delta_3')");
+
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.enableCheckpointing(100);
+        env.setParallelism(1);
+
+        EnvironmentSettings settings = 
EnvironmentSettings.newInstance().inStreamingMode().build();
+        StreamTableEnvironment streamTableEnv = 
StreamTableEnvironment.create(env, settings);
+
+        streamTableEnv.registerCatalog("PAIMON", 
tEnv.getCatalog("PAIMON").get());
+        streamTableEnv.useCatalog("PAIMON");
+        streamTableEnv.useDatabase("default");
+
+        streamTableEnv.executeSql(
+                "CREATE TABLE IF NOT EXISTS source_refresh ("
+                        + "  id BIGINT,"
+                        + "  v BIGINT,"
+                        + "  proc_time AS PROCTIME()"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon'"
+                        + ")");
+
+        streamTableEnv.executeSql(
+                "CREATE TABLE IF NOT EXISTS sink_refresh ("
+                        + "  id BIGINT,"
+                        + "  k BIGINT,"
+                        + "  v STRING"
+                        + ") WITH ("
+                        + "  'connector' = 'paimon',"
+                        + "  'primary-key' = 'id',"
+                        + "  'bucket' = '1'"
+                        + ")");
+
+        streamTableEnv
+                .executeSql("INSERT INTO source_refresh VALUES (1, 1), (2, 2), 
(3, 3)")
+                .await();
+
+        // Submit lookup join job BEFORE inserting source data
+        String query =
+                String.format(
+                        "INSERT INTO sink_refresh "
+                                + "SELECT S.id, D.k, D.v "
+                                + "FROM source_refresh AS S "
+                                + "LEFT JOIN "
+                                + tableName
+                                + " /*+ OPTIONS('lookup.cache' = 'full', 
'lookup.refresh-async' = '%s', 'continuous.refresh-interval' = '1s') */ "
+                                + "FOR SYSTEM_TIME AS OF S.proc_time AS D "
+                                + "ON S.id = D.k",
+                        asyncRefresh);
+
+        TableResult tableResult = streamTableEnv.executeSql(query);
+        JobClient jobClient =
+                tableResult
+                        .getJobClient()
+                        .orElseThrow(() -> new RuntimeException("Failed to get 
JobClient"));
+
+        try {
+            waitForJobRunning(jobClient);
+
+            waitForQueryResult(
+                    "SELECT * FROM sink_refresh ORDER BY id", results -> 
results.size() == 3);
+
+            // Insert new delta data
+            sql(
+                    "INSERT INTO `"
+                            + tableName
+                            + "$branch_delta` PARTITION (dt = '20250810') 
VALUES (4, 1, 'delta_4')");
+
+            waitForQueryResult(
+                    "SELECT * FROM " + tableName + " WHERE dt = '20250810' 
ORDER BY k",
+                    results -> results.size() == 4);
+
+            long startTime = System.currentTimeMillis();
+            long value = 0;
+            List<String> results = null;
+            while (System.currentTimeMillis() - startTime < 30000L) {
+                // Insert new source data to trigger lookup
+                streamTableEnv
+                        .executeSql("INSERT INTO source_refresh VALUES (4, " + 
value + ")")
+                        .await();
+
+                results = collectResult("SELECT * FROM sink_refresh WHERE id = 
4");
+                if (results.size() == 1 && results.get(0).contains("delta_4")) 
{
+                    break;
+                }
+                Thread.sleep(500);
+            }
+
+            assertThat(results).hasSize(1).contains("+I[4, 4, delta_4]");
+        } finally {
+            jobClient.cancel().get();
+        }
+    }
+
+    /**
+     * Tests that {@link FullCacheLookupTable#refresh()} uses the delta 
branch's snapshot manager
+     * for chain tables, so that async refresh is chosen when the delta 
backlog is small.
+     */
+    @Test
+    @Timeout(120)
+    public void testChainTableLookupRefreshAsyncPath() throws Exception {
+        String tableName = "chain_refresh_async_path";
+
+        tEnv.useCatalog("PAIMON");
+        tEnv.useDatabase("default");
+
+        sql(
+                "CREATE TABLE "
+                        + tableName
+                        + " ("
+                        + "  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'"
+                        + ")");
+        setupChainTableBranches(tableName);
+
+        // Write initial data to delta branch so the lookup table can 
bootstrap.
+        sql(
+                "INSERT OVERWRITE `"
+                        + tableName
+                        + "$branch_delta` PARTITION (dt = '20250808')"
+                        + " VALUES (1, 1, 'delta_1')");
+
+        // Get the chain table and enable async refresh with a 
pending-snapshot-count of 0.
+        FileStoreTable table = paimonTable(tableName);
+        Map<String, String> lookupOptions = new HashMap<>();
+        lookupOptions.put("lookup.refresh.async", "true");
+        lookupOptions.put("lookup.refresh.async.pending-snapshot-count", "0");
+        table = table.copy(lookupOptions);
+
+        // Create and open a FullCacheLookupTable directly so we can inspect 
refreshFuture.
+        File tempDir = new File(temporaryFolder.toFile(), tableName);
+        tempDir.mkdirs();
+        FullCacheLookupTable.Context context =
+                new FullCacheLookupTable.Context(
+                        table,
+                        new int[] {0, 1, 2, 3},
+                        null,
+                        null,
+                        tempDir,
+                        Collections.singletonList("k"),
+                        null);
+        FullCacheLookupTable lookupTable = 
FullCacheLookupTable.create(context, 0);
+        lookupTable.open();
+
+        try {
+            // Write many commits to the MAIN table. The main table and delta 
branch maintain
+            // independent snapshot sequences, so this inflates the main 
table's snapshot id
+            // while the delta branch remains at a low snapshot id.
+            for (int i = 0; i < 10; i++) {
+                sql(
+                        "INSERT INTO "
+                                + tableName
+                                + " PARTITION (dt = '20250810')"
+                                + " VALUES ("
+                                + (100 + i)
+                                + ", "
+                                + i
+                                + ", 'main_"
+                                + i
+                                + "')");
+            }
+
+            // Write a single new delta record so the delta branch has a small 
backlog.
+            sql(
+                    "INSERT INTO `"
+                            + tableName
+                            + "$branch_delta` PARTITION (dt = '20250810')"
+                            + " VALUES (3, 1, 'delta_3')");
+
+            // The delta branch has only one new snapshot, so the async 
refresh path should be
+            // chosen.
+            lookupTable.refresh();
+
+            assertThat(lookupTable.getRefreshFuture())
+                    .as(
+                            "Chain table lookup refresh should use the async 
path when the delta backlog is small.")
+                    .isNotNull();
+
+            // Wait for the async refresh to complete before closing.
+            lookupTable.getRefreshFuture().get();
+        } finally {
+            lookupTable.close();
+        }
+    }
+
+    /** Helper method: poll a query until the result matches the expected 
condition or timeout. */
+    private void waitForQueryResult(
+            String query, java.util.function.Predicate<List<String>> 
condition) throws Exception {
+        long startTime = System.currentTimeMillis();
+        while (System.currentTimeMillis() - startTime < 30000L) {
+            List<String> results = collectResult(query);
+            if (condition.test(results)) {
+                return;
+            }
+            Thread.sleep(500);
+        }
+        throw new RuntimeException("Timed out waiting for query result: " + 
query);
+    }
+
+    /** Helper method: wait for job to reach target status. */
+    private void waitForJobRunning(JobClient jobClient) throws Exception {
+        long startTime = System.currentTimeMillis();
+        JobStatus currentStatus = null;
+        while (System.currentTimeMillis() - startTime < (long) 30000) {
+            CompletableFuture<JobStatus> statusFuture = 
jobClient.getJobStatus();
+            currentStatus = statusFuture.get();
+
+            if (currentStatus == JobStatus.RUNNING) {
+                return;
+            }
+
+            if (currentStatus.isGloballyTerminalState()) {
+                throw new RuntimeException(
+                        "Job terminated unexpectedly with status: " + 
currentStatus);
+            }
+
+            Thread.sleep(500);
+        }
+        throw new RuntimeException(
+                "Timed out waiting for job status running. Current status: " + 
currentStatus);
+    }
 }

Reply via email to