This is an automated email from the ASF dual-hosted git repository.

fhueske pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new 087e8772291 [FLINK-40902][table] Support LATERAL SNAPSHOT join without 
an equi-key (#29421)
087e8772291 is described below

commit 087e8772291b65b7c22f60a3846a5e181cc9bea7
Author: Fabian Hueske <[email protected]>
AuthorDate: Tue Oct 13 10:29:21 2026 +0200

    [FLINK-40902][table] Support LATERAL SNAPSHOT join without an equi-key 
(#29421)
    
    * [FLINK-40902][table] Support LATERAL SNAPSHOT join without an equi-key
    
    LATERAL SNAPSHOT join required at least one equality predicate and rejected 
any
    join without one. This also rejected valid equi-joins whose equi-key was 
folded
    to a constant by a local filter on the join key (e.g. ON o.k = c.k WHERE 
o.k =
    'x'): Calcite pins both sides to the constant and simplifies the join 
condition,
    leaving no equi-key for the rule to find.
    
    Lift the equi-key requirement so LATERAL SNAPSHOT behaves like other Flink 
joins
    that tolerate a missing equi-key. A join with an equi-key is unchanged. A 
join
    without one is executed single-threaded in streaming (SINGLETON 
distribution; the
    operator already supports an empty key) and as a broadcast nested-loop join 
in
    batch, which keeps the probe side parallel.
    
    This resolves FLINK-40902: the filtered query above now plans and executes
    correctly instead of being rejected.
    
    Generated-by: Claude Code (Claude Opus 4.8)
---
 .../content.zh/docs/sql/reference/queries/joins.md |   4 +-
 docs/content/docs/sql/reference/queries/joins.md   |   4 +-
 .../LogicalJoinToLateralSnapshotJoinRule.java      |   8 -
 .../BatchPhysicalLateralSnapshotJoinRule.java      |  54 +++++--
 .../StreamPhysicalLateralSnapshotJoinRule.java     |   3 +-
 .../batch/sql/join/LateralSnapshotJoinTest.java    |  28 +++-
 .../LateralSnapshotJoinBatchSemanticTests.java     |   3 +-
 .../batch/LateralSnapshotJoinTestPrograms.java     |  32 +++-
 .../LateralSnapshotJoinSemanticTestPrograms.java   |  82 +++++++++-
 .../stream/LateralSnapshotJoinSemanticTests.java   |   3 +
 .../stream/sql/join/LateralSnapshotJoinTest.java   |  54 +++++--
 .../batch/sql/join/LateralSnapshotJoinTest.xml     |  46 ++++++
 .../stream/sql/join/LateralSnapshotJoinTest.xml    |  88 ++++++++++
 .../join/snapshot/LateralSnapshotJoinOperator.java |   9 ++
 .../snapshot/LateralSnapshotJoinOperatorTest.java  | 177 +++++++++++++++++++++
 15 files changed, 543 insertions(+), 52 deletions(-)

diff --git a/docs/content.zh/docs/sql/reference/queries/joins.md 
b/docs/content.zh/docs/sql/reference/queries/joins.md
index 1d83fd7e270..012d8b14327 100644
--- a/docs/content.zh/docs/sql/reference/queries/joins.md
+++ b/docs/content.zh/docs/sql/reference/queries/joins.md
@@ -380,7 +380,7 @@ If a `LATERAL SNAPSHOT` join produces no output, first 
check whether it is stuck
 **Syntax**
 
 The build side is wrapped in the `SNAPSHOT` table function, which is called 
with `LATERAL`. The outer (probe-side) table must be an append-only table. 
-Both `INNER JOIN` and `LEFT [OUTER] JOIN` are supported. The join requires at 
least one conjunctive equality predicate; additional non-equi predicates are 
allowed in the `ON` clause.
+Both `INNER JOIN` and `LEFT [OUTER] JOIN` are supported. An equality predicate 
is recommended but not required: the `ON` clause may contain equality and 
non-equi predicates in any combination. In streaming mode, a join with at least 
one equality predicate is partitioned on the join key and runs in parallel, 
whereas a join without one runs single-threaded; prefer an equality predicate 
for better performance. In batch mode, a join without an equality predicate 
uses a broadcast nested-loop  [...]
 
 ```sql
 SELECT [column_list]
@@ -412,7 +412,7 @@ The result is append-only and preserves the probe-side time 
attributes. A build-
 
 Because rows are joined against the build-side state that is current at 
processing time, the result is **not deterministic**. A given probe-side row 
may be joined with different build-side versions across different runs, 
depending on the relative timing of the two inputs. Probe and build-side inputs 
can be configured with watermark alignment to keep the two inputs roughly 
aligned on event time, so that a probe-side row tends to be joined with 
build-side changes of a similar event time. T [...]
 
-The build-side state grows with the number of distinct build-side keys, and 
during the load phase the buffered probe-side rows add to the state footprint 
until the operator transitions to the join phase. Use `state_ttl` to bound the 
build-side state for keys that are no longer updated or joined. You can reduce 
the amount of data that is buffered and processed during the load phase by 
configuring scan start offsets on the build and probe-side inputs, for example 
with a `scan.startup.*` [d [...]
+The build-side state grows with the number of distinct build-side keys, and 
during the load phase the buffered probe-side rows add to the state footprint 
until the operator transitions to the join phase. Use `state_ttl` to bound the 
build-side state for keys that are no longer updated or joined. Without an 
equality predicate the join has no join key, so all build-side rows share a 
single state entry; `state_ttl` can then only evict the whole build side at 
once, once it has been idle (no  [...]
 
 **Monitoring**
 
diff --git a/docs/content/docs/sql/reference/queries/joins.md 
b/docs/content/docs/sql/reference/queries/joins.md
index ba31ded4566..2a5a4b07182 100644
--- a/docs/content/docs/sql/reference/queries/joins.md
+++ b/docs/content/docs/sql/reference/queries/joins.md
@@ -389,7 +389,7 @@ If a `LATERAL SNAPSHOT` join produces no output, first 
check whether it is stuck
 **Syntax**
 
 The build side is wrapped in the `SNAPSHOT` table function, which is called 
with `LATERAL`. The outer (probe-side) table must be an append-only table. 
-Both `INNER JOIN` and `LEFT [OUTER] JOIN` are supported. The join requires at 
least one conjunctive equality predicate; additional non-equi predicates are 
allowed in the `ON` clause.
+Both `INNER JOIN` and `LEFT [OUTER] JOIN` are supported. An equality predicate 
is recommended but not required: the `ON` clause may contain equality and 
non-equi predicates in any combination. In streaming mode, a join with at least 
one equality predicate is partitioned on the join key and runs in parallel, 
whereas a join without one runs single-threaded; prefer an equality predicate 
for better performance. In batch mode, a join without an equality predicate 
uses a broadcast nested-loop  [...]
 
 ```sql
 SELECT [column_list]
@@ -421,7 +421,7 @@ The result is append-only and preserves the probe-side time 
attributes. A build-
 
 Because rows are joined against the build-side state that is current at 
processing time, the result is **not deterministic**. A given probe-side row 
may be joined with different build-side versions across different runs, 
depending on the relative timing of the two inputs. Probe and build-side inputs 
can be configured with watermark alignment to keep the two inputs roughly 
aligned on event time, so that a probe-side row tends to be joined with 
build-side changes of a similar event time. T [...]
 
-The build-side state grows with the number of distinct build-side keys, and 
during the load phase the buffered probe-side rows add to the state footprint 
until the operator transitions to the join phase. Use `state_ttl` to bound the 
build-side state for keys that are no longer updated or joined. You can reduce 
the amount of data that is buffered and processed during the load phase by 
configuring scan start offsets on the build and probe-side inputs, for example 
with a `scan.startup.*` [d [...]
+The build-side state grows with the number of distinct build-side keys, and 
during the load phase the buffered probe-side rows add to the state footprint 
until the operator transitions to the join phase. Use `state_ttl` to bound the 
build-side state for keys that are no longer updated or joined. Without an 
equality predicate the join has no join key, so all build-side rows share a 
single state entry; `state_ttl` can then only evict the whole build side at 
once, once it has been idle (no  [...]
 
 **Monitoring**
 
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
index 6edb403f934..325e7128c27 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
@@ -35,7 +35,6 @@ import org.apache.calcite.plan.RelOptRuleCall;
 import org.apache.calcite.plan.RelRule;
 import org.apache.calcite.plan.hep.HepRelVertex;
 import org.apache.calcite.rel.RelNode;
-import org.apache.calcite.rel.core.JoinInfo;
 import org.apache.calcite.rel.core.JoinRelType;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeField;
@@ -107,13 +106,6 @@ public class LogicalJoinToLateralSnapshotJoinRule
                             joinType));
         }
 
-        // Require at least one equality predicate so the operator can 
hash-partition both inputs.
-        final JoinInfo joinInfo = join.analyzeCondition();
-        if (joinInfo.leftKeys.isEmpty()) {
-            throw new ValidationException(
-                    "LATERAL SNAPSHOT join requires at least one equality 
predicate.");
-        }
-
         final RexCall snapshotCall = (RexCall) scan.getCall();
 
         // Replace the SNAPSHOT TableFunctionScan with its TABLE input, 
preserving any
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
index 9338ffb8feb..dc4ecf8fa6a 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
@@ -21,6 +21,7 @@ package 
org.apache.flink.table.planner.plan.rules.physical.batch;
 import org.apache.flink.table.planner.plan.nodes.FlinkConventions;
 import 
org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalLateralSnapshotJoin;
 import 
org.apache.flink.table.planner.plan.nodes.physical.batch.BatchPhysicalHashJoin;
+import 
org.apache.flink.table.planner.plan.nodes.physical.batch.BatchPhysicalNestedLoopJoin;
 import 
org.apache.flink.table.planner.plan.rules.logical.LogicalJoinToLateralSnapshotJoinRule;
 import 
org.apache.flink.table.planner.plan.rules.physical.stream.StreamPhysicalLateralSnapshotJoinRule;
 import org.apache.flink.table.planner.plan.trait.FlinkRelDistribution;
@@ -34,8 +35,9 @@ import org.apache.calcite.util.ImmutableIntList;
 
 /**
  * Converts a {@link FlinkLogicalLateralSnapshotJoin} (created by {@link
- * LogicalJoinToLateralSnapshotJoinRule}) into a regular batch {@link 
BatchPhysicalHashJoin} for
- * batch execution.
+ * LogicalJoinToLateralSnapshotJoinRule}) into a regular batch join for batch 
execution: a {@link
+ * BatchPhysicalHashJoin} when the join has an equi-key, or a {@link 
BatchPhysicalNestedLoopJoin}
+ * when it does not.
  *
  * <p>In batch all input is bounded and append-only (batch rejects or in the 
future materializes
  * non-insert-only sources up front), so the processing-time {@code LATERAL 
SNAPSHOT} join
@@ -45,8 +47,10 @@ import org.apache.calcite.util.ImmutableIntList;
  * stream operator.
  *
  * <p>The SNAPSHOT input is the build (right) side of the LATERAL join and is 
the dimension-like
- * side, expected to be smaller than the probe (left) side. The join is 
therefore emitted as a
- * shuffle hash join that builds on the right input.
+ * side, expected to be smaller than the probe (left) side. With an equi-key 
the join is emitted as
+ * a shuffle hash join that builds on the right input; without an equi-key 
(e.g. a non-equi
+ * condition, or an equi-key that a local filter folded to a constant) it is 
emitted as a broadcast
+ * nested-loop join that broadcasts the right input, keeping the probe side 
parallel.
  */
 public class BatchPhysicalLateralSnapshotJoinRule extends ConverterRule {
 
@@ -68,8 +72,40 @@ public class BatchPhysicalLateralSnapshotJoinRule extends 
ConverterRule {
         final RelTraitSet providedTraitSet =
                 rel.getTraitSet().replace(FlinkConventions.BATCH_PHYSICAL());
 
-        // Both inputs are hash-partitioned on their join keys (shuffle hash 
join).
         final JoinInfo joinInfo = join.analyzeCondition();
+        if (joinInfo.leftKeys.isEmpty()) {
+            // Without an equi-key a hash join is not applicable (the join is 
non-equi, or its
+            // equi-key was constant-folded away by a local filter on the join 
key). Mirror how
+            // regular batch joins handle this and emit a broadcast 
nested-loop join that broadcasts
+            // the (smaller, dimension-like) SNAPSHOT build side to the probe 
side, which keeps the
+            // probe side parallel.
+            final RelNode newLeft =
+                    RelOptRule.convert(
+                            join.getLeft(),
+                            join.getLeft()
+                                    .getTraitSet()
+                                    
.replace(FlinkConventions.BATCH_PHYSICAL()));
+            final RelNode newRight =
+                    RelOptRule.convert(
+                            join.getRight(),
+                            join.getRight()
+                                    .getTraitSet()
+                                    .replace(FlinkConventions.BATCH_PHYSICAL())
+                                    
.replace(FlinkRelDistribution.BROADCAST_DISTRIBUTED()));
+            return new BatchPhysicalNestedLoopJoin(
+                    join.getCluster(),
+                    providedTraitSet,
+                    newLeft,
+                    newRight,
+                    join.getCondition(),
+                    join.getJoinType(),
+                    // leftIsBuild = false: build the right (SNAPSHOT) side.
+                    false,
+                    // singleRowJoin = false: neither side is known to produce 
a single row.
+                    false);
+        }
+
+        // Both inputs are hash-partitioned on their join keys (shuffle hash 
join).
         final RelNode newLeft = convertInput(join.getLeft(), 
joinInfo.leftKeys);
         final RelNode newRight = convertInput(join.getRight(), 
joinInfo.rightKeys);
 
@@ -92,17 +128,13 @@ public class BatchPhysicalLateralSnapshotJoinRule extends 
ConverterRule {
 
     /**
      * Converts a join input to the batch-physical convention and requires it 
to be hash-partitioned
-     * on the given join {@code keys} (or a singleton distribution if there 
are none).
+     * on the given join {@code keys}.
      */
     private static RelNode convertInput(RelNode input, ImmutableIntList keys) {
-        final FlinkRelDistribution distribution =
-                keys.isEmpty()
-                        ? FlinkRelDistribution.SINGLETON()
-                        : FlinkRelDistribution.hash(keys.toIntArray(), true);
         final RelTraitSet requiredTraitSet =
                 input.getTraitSet()
                         .replace(FlinkConventions.BATCH_PHYSICAL())
-                        .replace(distribution);
+                        .replace(FlinkRelDistribution.hash(keys.toIntArray(), 
true));
         return RelOptRule.convert(input, requiredTraitSet);
     }
 }
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
index ec8010deed1..2d1408cbddb 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
@@ -56,7 +56,8 @@ public class StreamPhysicalLateralSnapshotJoinRule extends 
ConverterRule {
         final RelTraitSet providedTraitSet =
                 rel.getTraitSet().replace(FlinkConventions.STREAM_PHYSICAL());
 
-        // Both inputs are hash-partitioned on their join keys.
+        // Both inputs are hash-partitioned on their join keys, or co-located 
with a singleton
+        // distribution when the join has no equi-key
         final JoinInfo joinInfo = join.analyzeCondition();
         final RelNode newLeft = convertInput(join.getLeft(), 
joinInfo.leftKeys);
         final RelNode newRight = convertInput(join.getRight(), 
joinInfo.rightKeys);
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
index 11aefe68908..1849f672d34 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
@@ -152,23 +152,35 @@ public class LateralSnapshotJoinTest extends 
TableTestBase {
     }
 
     // 
------------------------------------------------------------------------------------------
-    // Validation: rejection paths
+    // Joins without an equi-key (degrade to a nested-loop join)
     // 
------------------------------------------------------------------------------------------
 
     @Test
-    void testRejectMissingEqualityPredicate() {
-        final String sql =
+    void testNonEquiJoinUsesNestedLoopJoin() {
+        // Keyless inner join: execute as broadcast NL join.
+        util.verifyRelPlan(
                 "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
                         + "input => TABLE b, "
                         + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
                         + ") AS s "
-                        + "ON probe.pv > s.bv";
-        assertThatThrownBy(() -> util.verifyExecPlan(sql))
-                .isInstanceOf(ValidationException.class)
-                .hasMessageContaining(
-                        "LATERAL SNAPSHOT join requires at least one equality 
predicate.");
+                        + "ON probe.pv > s.bv");
+    }
+
+    @Test
+    void testLeftJoinWithoutEquiKeyUsesNestedLoopJoin() {
+        // Keyless left outer join: execute as broadcast NL join.
+        util.verifyRelPlan(
+                "SELECT * FROM probe LEFT JOIN LATERAL SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ") AS s "
+                        + "ON probe.pv > s.bv");
     }
 
+    // 
------------------------------------------------------------------------------------------
+    // Validation: rejection paths
+    // 
------------------------------------------------------------------------------------------
+
     @Test
     void testRejectSnapshotOutsideLateral() {
         // SNAPSHOT used outside a LATERAL context is not rewritten into a 
join;
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
index 87725a60a26..e575e54d128 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
@@ -36,7 +36,8 @@ public class LateralSnapshotJoinBatchSemanticTests extends 
BatchSemanticTestBase
         return List.of(
                 LateralSnapshotJoinTestPrograms.INNER_JOIN,
                 LateralSnapshotJoinTestPrograms.LEFT_JOIN,
-                
LateralSnapshotJoinTestPrograms.INNER_JOIN_WITH_NON_EQUI_CONDITION,
+                LateralSnapshotJoinTestPrograms.COMPLEX_PRED,
+                LateralSnapshotJoinTestPrograms.NON_EQUI,
                 LateralSnapshotJoinTestPrograms.SNAPSHOT_ARGUMENTS_IGNORED);
     }
 }
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
index f33e94e5c5c..589fc440f77 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
@@ -85,10 +85,10 @@ public class LateralSnapshotJoinTestPrograms {
                                     + "SNAPSHOT(input => TABLE b) AS s ON 
probe.pk = s.bk")
                     .build();
 
-    public static final TableTestProgram INNER_JOIN_WITH_NON_EQUI_CONDITION =
+    public static final TableTestProgram COMPLEX_PRED =
             TableTestProgram.of(
-                            "lateral-snapshot-join-non-equi",
-                            "batch LATERAL SNAPSHOT join with an additional 
non-equi predicate")
+                            "lateral-snapshot-join-complex-pred",
+                            "batch LATERAL SNAPSHOT join on an equi-key with 
an additional non-equi predicate")
                     .setupTableSource(probe())
                     .setupTableSource(build())
                     .setupTableSink(
@@ -103,6 +103,32 @@ public class LateralSnapshotJoinTestPrograms {
                                     + "ON probe.pk = s.bk AND s.bv > 10")
                     .build();
 
+    public static final TableTestProgram NON_EQUI =
+            TableTestProgram.of(
+                            "lateral-snapshot-join-non-equi",
+                            "batch LATERAL SNAPSHOT join with only a non-equi 
condition (no equi-key) "
+                                    + "uses a broadcast nested-loop join")
+                    .setupTableSource(
+                            SourceTestStep.newBuilder("probe")
+                                    .addSchema("pk STRING", "pv INT")
+                                    .producedValues(
+                                            Row.of("a", 5), Row.of("b", 15), 
Row.of("c", 25))
+                                    .build())
+                    .setupTableSource(
+                            SourceTestStep.newBuilder("b")
+                                    .addSchema("bk STRING", "bv INT")
+                                    .producedValues(Row.of("a", 10), 
Row.of("b", 20))
+                                    .build())
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pv INT", "bv INT")
+                                    .consumedValues(Row.of(5, 10), Row.of(5, 
20), Row.of(15, 20))
+                                    .build())
+                    .runSql(
+                            "INSERT INTO sink SELECT pv, bv FROM probe JOIN 
LATERAL "
+                                    + "SNAPSHOT(input => TABLE b) AS s ON 
probe.pv < s.bv")
+                    .build();
+
     public static final TableTestProgram SNAPSHOT_ARGUMENTS_IGNORED =
             TableTestProgram.of(
                             "lateral-snapshot-join-arguments-ignored",
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
index 0d951924c87..6dc6ddf719f 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
@@ -174,8 +174,10 @@ public class LateralSnapshotJoinSemanticTestPrograms {
                                     "probe.pk = s.bk AND probe.pv = s.bv"))
                     .build();
 
-    public static final TableTestProgram NON_EQUI =
-            TableTestProgram.of("lateral-snapshot-non-equi", "join with a 
non-equi condition")
+    public static final TableTestProgram COMPLEX_PRED =
+            TableTestProgram.of(
+                            "lateral-snapshot-complex-pred",
+                            "join on an equi-key with an additional non-equi 
predicate")
                     .setupTableSource(
                             probe(
                                     Arrays.asList(
@@ -200,6 +202,82 @@ public class LateralSnapshotJoinSemanticTestPrograms {
                                     "probe.pk = s.bk AND probe.pv > s.bv"))
                     .build();
 
+    public static final TableTestProgram NON_EQUI =
+            TableTestProgram.of(
+                            "lateral-snapshot-non-equi",
+                            "join with only a non-equi condition (no equi-key) 
runs single-threaded")
+                    .setupTableSource(
+                            probe(
+                                    Arrays.asList(
+                                            Row.of("a", 5, ts("00:01:00")),
+                                            Row.of("a", 15, ts("00:01:01")))))
+                    .setupTableSource(
+                            appendBuild(
+                                    withFlipTrigger(
+                                            Arrays.asList(
+                                                    Row.of("a", 10, 
ts("00:00:01")),
+                                                    Row.of("a", 20, 
ts("00:00:02"))))))
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pv INT", "bv INT")
+                                    .testMaterializedData()
+                                    .consumedValues("+I[5, 10]", "+I[5, 20]", 
"+I[15, 20]")
+                                    .build())
+                    // probe.pv < s.bv keeps the bv=0 flip-trigger row from 
matching any probe.
+                    .runSql(innerJoin("probe.pv, s.bv", MID_FLIP, "probe.pv < 
s.bv"))
+                    .build();
+
+    public static final TableTestProgram NON_EQUI_LEFT =
+            TableTestProgram.of(
+                            "lateral-snapshot-non-equi-left",
+                            "left join with only a non-equi condition 
null-pads unmatched probe rows")
+                    .setupTableSource(
+                            probe(
+                                    Arrays.asList(
+                                            Row.of("a", 5, ts("00:01:00")),
+                                            Row.of("a", 100, ts("00:01:01")))))
+                    .setupTableSource(
+                            appendBuild(
+                                    withFlipTrigger(
+                                            Arrays.asList(
+                                                    Row.of("a", 10, 
ts("00:00:01")),
+                                                    Row.of("a", 20, 
ts("00:00:02"))))))
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pv INT", "bv INT")
+                                    .testMaterializedData()
+                                    .consumedValues("+I[5, 10]", "+I[5, 20]", 
"+I[100, null]")
+                                    .build())
+                    // probe.pv < s.bv keeps the bv=0 flip-trigger row from 
matching any probe.
+                    .runSql(leftJoin("probe.pv, s.bv", MID_FLIP, "probe.pv < 
s.bv"))
+                    .build();
+
+    public static final TableTestProgram CROSS_JOIN =
+            TableTestProgram.of(
+                            "lateral-snapshot-cross-join",
+                            "inner join with a TRUE condition produces the 
probe-build cross product")
+                    .setupTableSource(
+                            probe(
+                                    Arrays.asList(
+                                            Row.of("a", 5, ts("00:01:00")),
+                                            Row.of("a", 15, ts("00:01:01")))))
+                    // No flip-trigger row: with a TRUE condition it would 
match every probe. The
+                    // far-future flip condition flips the operator only at 
end-of-input.
+                    .setupTableSource(
+                            appendBuild(
+                                    Arrays.asList(
+                                            Row.of("a", 10, ts("00:00:01")),
+                                            Row.of("a", 20, ts("00:00:02")))))
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pv INT", "bv INT")
+                                    .testMaterializedData()
+                                    .consumedValues(
+                                            "+I[5, 10]", "+I[5, 20]", "+I[15, 
10]", "+I[15, 20]")
+                                    .build())
+                    .runSql(innerJoin("probe.pv, s.bv", END_FLIP, "TRUE"))
+                    .build();
+
     public static final TableTestProgram EMPTY_BUILD_INNER =
             TableTestProgram.of(
                             "lateral-snapshot-empty-build-inner",
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
index 5ab4917eaea..4805f282875 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
@@ -32,7 +32,10 @@ public class LateralSnapshotJoinSemanticTests extends 
SemanticTestBase {
                 LateralSnapshotJoinSemanticTestPrograms.LEFT_JOIN,
                 LateralSnapshotJoinSemanticTestPrograms.SELECT_STAR,
                 LateralSnapshotJoinSemanticTestPrograms.COMPOSITE_KEYS,
+                LateralSnapshotJoinSemanticTestPrograms.COMPLEX_PRED,
                 LateralSnapshotJoinSemanticTestPrograms.NON_EQUI,
+                LateralSnapshotJoinSemanticTestPrograms.NON_EQUI_LEFT,
+                LateralSnapshotJoinSemanticTestPrograms.CROSS_JOIN,
                 LateralSnapshotJoinSemanticTestPrograms.EMPTY_BUILD_INNER,
                 LateralSnapshotJoinSemanticTestPrograms.EMPTY_BUILD_LEFT,
                 LateralSnapshotJoinSemanticTestPrograms.FLIP_AT_END,
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
index aa5ee760490..1873de41b9f 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
@@ -207,6 +207,46 @@ public class LateralSnapshotJoinTest extends TableTestBase 
{
                         + "ON probe.pk = s.bk");
     }
 
+    @Test
+    void testNonEquiInnerLateralSnapshotJoin() {
+        // An INNER LATERAL SNAPSHOT join without an equi-key runs with a 
SINGLETON distribution
+        // (single-threaded), like other Flink joins without an equi-key.
+        util.verifyRelPlan(
+                "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
+                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ") AS s "
+                        + "ON probe.pv > s.bv");
+    }
+
+    @Test
+    void testNonEquiLeftLateralSnapshotJoin() {
+        // A LEFT OUTER LATERAL SNAPSHOT join without an equi-key runs with a 
SINGLETON distribution
+        // (single-threaded), like other Flink joins without an equi-key.
+        util.verifyRelPlan(
+                "SELECT * FROM probe LEFT JOIN LATERAL SNAPSHOT("
+                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ") AS s "
+                        + "ON probe.pv > s.bv");
+    }
+
+    @Test
+    void testLocalPredicateOnJoinKeyColumn() {
+        // Reproduces FLINK-40902: a local filter on the same column that 
carries the equi-join
+        // predicate (probe.pk = s.bk together with WHERE probe.pk = 'key1') 
must still plan as a
+        // LATERAL SNAPSHOT join. Calcite folds the equi-key to a constant on 
each side (pk = 'key1'
+        // AND bk = 'key1') and simplifies the join condition away; the join 
then runs single-
+        // threaded (SINGLETON).
+        util.verifyRelPlan(
+                "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
+                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ") AS s "
+                        + "ON probe.pk = s.bk "
+                        + "WHERE probe.pk = 'key1'");
+    }
+
     // 
------------------------------------------------------------------------------------------
     // Behavior and compilation smoke tests
     // 
------------------------------------------------------------------------------------------
@@ -510,20 +550,6 @@ public class LateralSnapshotJoinTest extends TableTestBase 
{
                                 + "consuming update and delete changes");
     }
 
-    @Test
-    void testRejectMissingEqualityPredicate() {
-        final String sql =
-                "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
-                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
-                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
-                        + ") AS s "
-                        + "ON probe.pv > s.bv";
-        assertThatThrownBy(() -> util.verifyRelPlan(sql))
-                .isInstanceOf(ValidationException.class)
-                .hasMessageContaining(
-                        "LATERAL SNAPSHOT join requires at least one equality 
predicate.");
-    }
-
     @Test
     void testRejectNonConstantLoadCompletedTime() {
         final String sql =
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
index 3f2e196ab54..17cfaa08bde 100644
--- 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
@@ -185,6 +185,52 @@ HashJoin(joinType=[LeftOuterJoin], where=[=(pk, bk)], 
select=[pk, pv, pts, bk, b
 :  +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
 +- Exchange(distribution=[hash[bk]])
    +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testLeftJoinWithoutEquiKeyUsesNestedLoopJoin">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe LEFT JOIN LATERAL SNAPSHOT(input => TABLE 
b, load_completed_time => CAST(TIMESTAMP '2026-07-01 00:00:00' AS 
TIMESTAMP_LTZ(3))) AS s ON probe.pv > s.bv]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[>($1, $4)], joinType=[left])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(), 
CAST(2026-07-01 00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, 
DEFAULT(), DEFAULT())], rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, 
TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+NestedLoopJoin(joinType=[LeftOuterJoin], where=[>(pv, bv)], select=[pk, pv, 
pts, bk, bv, bts], build=[right])
+:- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[broadcast])
+   +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNonEquiJoinUsesNestedLoopJoin">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b, 
load_completed_time => CAST(TIMESTAMP '2026-07-01 00:00:00' AS 
TIMESTAMP_LTZ(3))) AS s ON probe.pv > s.bv]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[>($1, $4)], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(), 
CAST(2026-07-01 00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, 
DEFAULT(), DEFAULT())], rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, 
TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+NestedLoopJoin(joinType=[InnerJoin], where=[>(pv, bv)], select=[pk, pv, pts, 
bk, bv, bts], build=[right])
+:- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[broadcast])
+   +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
 ]]>
     </Resource>
   </TestCase>
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
index ecfbe8d985e..5a4bdb252ca 100644
--- 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
@@ -304,6 +304,94 @@ Calc(select=[pk, pv, bv])
    +- Exchange(distribution=[hash[bk]])
       +- WatermarkAssigner(rowtime=[bts], watermark=[bts])
          +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testLocalPredicateOnJoinKeyColumn">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b, 
on_time => DESCRIPTOR(bts), load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))) AS s ON probe.pk = s.bk WHERE probe.pk = 
'key1']]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalFilter(condition=[=($0, _UTF-16LE'key1')])
+   +- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
+      :- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
+      :  +- LogicalTableScan(table=[[default_catalog, default_database, 
probe]])
+      +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
DESCRIPTOR(_UTF-16LE'bts'), CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+         +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+            +- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+               +- LogicalTableScan(table=[[default_catalog, default_database, 
b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+Calc(select=[CAST('key1' AS VARCHAR(2147483647)) AS pk, pv, pts, CAST('key1' 
AS VARCHAR(2147483647)) AS bk, bv, bts])
++- LateralSnapshotJoin(joinType=[InnerJoin], where=[true], select=[pv, pts, 
bv, bts], loadCompletedCondition=[user_time], loadCompletedTime=[1782864000000])
+   :- Exchange(distribution=[single])
+   :  +- Calc(select=[pv, pts], where=[=(pk, 'key1')])
+   :     +- WatermarkAssigner(rowtime=[pts], watermark=[pts])
+   :        +- TableSourceScan(table=[[default_catalog, default_database, 
probe]], fields=[pk, pv, pts])
+   +- Exchange(distribution=[single])
+      +- Calc(select=[bv, bts], where=[=(bk, 'key1')])
+         +- WatermarkAssigner(rowtime=[bts], watermark=[bts])
+            +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNonEquiInnerLateralSnapshotJoin">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b, 
on_time => DESCRIPTOR(bts), load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))) AS s ON probe.pv > s.bv]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[>($1, $4)], joinType=[inner])
+   :- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
+   :  +- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
DESCRIPTOR(_UTF-16LE'bts'), CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+            +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+LateralSnapshotJoin(joinType=[InnerJoin], where=[>(pv, bv)], select=[pk, pv, 
pts, bk, bv, bts], loadCompletedCondition=[user_time], 
loadCompletedTime=[1782864000000])
+:- Exchange(distribution=[single])
+:  +- WatermarkAssigner(rowtime=[pts], watermark=[pts])
+:     +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[single])
+   +- WatermarkAssigner(rowtime=[bts], watermark=[bts])
+      +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNonEquiLeftLateralSnapshotJoin">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe LEFT JOIN LATERAL SNAPSHOT(input => TABLE 
b, on_time => DESCRIPTOR(bts), load_completed_time => CAST(TIMESTAMP 
'2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3))) AS s ON probe.pv > s.bv]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[>($1, $4)], joinType=[left])
+   :- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
+   :  +- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
DESCRIPTOR(_UTF-16LE'bts'), CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+            +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+LateralSnapshotJoin(joinType=[LeftOuterJoin], where=[>(pv, bv)], select=[pk, 
pv, pts, bk, bv, bts], loadCompletedCondition=[user_time], 
loadCompletedTime=[1782864000000])
+:- Exchange(distribution=[single])
+:  +- WatermarkAssigner(rowtime=[pts], watermark=[pts])
+:     +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[single])
+   +- WatermarkAssigner(rowtime=[bts], watermark=[bts])
+      +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
 ]]>
     </Resource>
   </TestCase>
diff --git 
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperator.java
 
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperator.java
index 628eebe12aa..d9c3acf43bb 100644
--- 
a/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperator.java
+++ 
b/flink-table/flink-table-runtime/src/main/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperator.java
@@ -512,6 +512,15 @@ public class LateralSnapshotJoinOperator extends 
AbstractStreamOperator<RowData>
                 minStateTtlMs,
                 maxStateTtlMs,
                 buildRowtimeIndex);
+
+        if (filterNullKeys.length == 0 && minStateTtlMs > 0) {
+            // No equi-key: all build-side rows share a single (empty) key, so 
state_ttl can only
+            // evict the whole build side at once after it has been idle 
longer than the TTL.
+            LOG.warn(
+                    "LATERAL SNAPSHOT join has no equi-key; state_ttl evicts 
the entire build side "
+                            + "at once after it has been idle for longer than 
the TTL, not per join "
+                            + "key.");
+        }
     }
 
     @Override
diff --git 
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperatorTest.java
 
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperatorTest.java
index 1fb82fad800..4f22e3f8572 100644
--- 
a/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperatorTest.java
+++ 
b/flink-table/flink-table-runtime/src/test/java/org/apache/flink/table/runtime/operators/join/snapshot/LateralSnapshotJoinOperatorTest.java
@@ -36,6 +36,7 @@ import org.apache.flink.table.data.StringData;
 import org.apache.flink.table.data.binary.BinaryRowData;
 import org.apache.flink.table.data.writer.BinaryRowWriter;
 import org.apache.flink.table.runtime.generated.GeneratedJoinCondition;
+import org.apache.flink.table.runtime.keyselector.EmptyRowDataKeySelector;
 import 
org.apache.flink.table.runtime.operators.join.snapshot.LateralSnapshotJoinOperator.Phase;
 import org.apache.flink.table.runtime.typeutils.InternalTypeInfo;
 import org.apache.flink.table.runtime.util.RowDataHarnessAssertor;
@@ -146,6 +147,22 @@ class LateralSnapshotJoinOperatorTest {
                     + "    }\n"
                     + "}\n";
 
+    /**
+     * Non-equi join condition that matches when the probe id (field 0) is 
less than the build
+     * row-time (field 2). Used to exercise the generated condition in 
empty-key (keyless) mode.
+     */
+    private static final String PROBE_ID_LT_BUILD_RT_JOIN_FUNC_CODE =
+            "public class LateralSnapshotJoinConditionIdLtRt extends "
+                    + 
"org.apache.flink.api.common.functions.AbstractRichFunction "
+                    + "implements 
org.apache.flink.table.runtime.generated.JoinCondition {\n"
+                    + "    public LateralSnapshotJoinConditionIdLtRt(Object[] 
reference) {}\n"
+                    + "    @Override public boolean apply("
+                    + "        org.apache.flink.table.data.RowData in1,"
+                    + "        org.apache.flink.table.data.RowData in2) {\n"
+                    + "        return in1.getLong(0) < in2.getLong(2);\n"
+                    + "    }\n"
+                    + "}\n";
+
     private static GeneratedJoinCondition newTrueCondition() {
         return new GeneratedJoinCondition(
                 "LateralSnapshotJoinConditionStub", 
ALWAYS_TRUE_JOIN_FUNC_CODE, new Object[0]);
@@ -156,6 +173,13 @@ class LateralSnapshotJoinOperatorTest {
                 "LateralSnapshotJoinConditionMatchVal", 
MATCH_VAL_JOIN_FUNC_CODE, new Object[0]);
     }
 
+    private static GeneratedJoinCondition newProbeIdLtBuildRtCondition() {
+        return new GeneratedJoinCondition(
+                "LateralSnapshotJoinConditionIdLtRt",
+                PROBE_ID_LT_BUILD_RT_JOIN_FUNC_CODE,
+                new Object[0]);
+    }
+
     // ----------------------------------------------------------------- 
Operator / harness
     // factories
 
@@ -200,6 +224,20 @@ class LateralSnapshotJoinOperatorTest {
                 op, PROBE_KEY_SELECTOR, BUILD_KEY_SELECTOR, KEY_TYPE);
     }
 
+    /**
+     * Harness that runs the operator in non-key (SINGLETON) mode: both inputs 
use {@link
+     * EmptyRowDataKeySelector}, so every row maps to one empty key. This is 
the shape a LATERAL
+     * SNAPSHOT join takes when it doesn't have an equality predicate.
+     */
+    private static KeyedTwoInputStreamOperatorTestHarness<RowData, RowData, 
RowData, RowData>
+            newEmptyKeyHarness(LateralSnapshotJoinOperator op) throws 
Exception {
+        return new KeyedTwoInputStreamOperatorTestHarness<>(
+                op,
+                EmptyRowDataKeySelector.INSTANCE,
+                EmptyRowDataKeySelector.INSTANCE,
+                EmptyRowDataKeySelector.INSTANCE.getProducedType());
+    }
+
     private static KeySelector<RowData, RowData> 
nullSafeStringKeySelector(final int keyIdx) {
         return value -> {
             BinaryRowData ret = new BinaryRowData(1);
@@ -509,6 +547,112 @@ class LateralSnapshotJoinOperatorTest {
         }
     }
 
+    @Test
+    void emptyKeyModeInnerNonEquiJoin() throws Exception {
+        // INNER JOIN with an empty keyset and a non-equi condition (probe.id 
< build.rt). This
+        // happens if the join has no equality predicate (or it was folded 
away). The empty key puts
+        // all build rows in one group; the generated condition then selects 
the matching ones.
+        LateralSnapshotJoinOperator op =
+                newOperator(
+                        false, newProbeIdLtBuildRtCondition(), new boolean[0], 
100L, null, null);
+        try (KeyedTwoInputStreamOperatorTestHarness<RowData, RowData, RowData, 
RowData> h =
+                newEmptyKeyHarness(op)) {
+            h.open();
+            addBuildChange(h, insertRecord("k1", "v1", 10L));
+            addBuildChange(h, insertRecord("k2", "v2", 20L));
+
+            // LOAD phase: buffered probe id=15 matches only k2 at the flip 
(15 < 20, not < 10).
+            addProbeRecord(h, 15L, "px", "p1");
+            addProbeWm(h, 80L); // lets the flip drain the buffered probe
+            addBuildWm(h, 100L); // flip to JOIN
+            assertPhase(op, Phase.JOIN);
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(h, row(15L, "px", "p1", "k2", "v2", 
20L));
+
+            // JOIN phase: id=5 matches k1 and k2; id=18 matches k2; id=25 
matches none (no output).
+            addProbeRecord(h, 5L, "py", "p2");
+            addProbeRecord(h, 18L, "pz", "p3");
+            addProbeRecord(h, 25L, "pz", "p4");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(
+                    h,
+                    row(5L, "py", "p2", "k1", "v1", 10L),
+                    row(5L, "py", "p2", "k2", "v2", 20L),
+                    row(18L, "pz", "p3", "k2", "v2", 20L));
+        }
+    }
+
+    @Test
+    void emptyKeyModeLeftOuterNonEquiJoin() throws Exception {
+        // LEFT OUTER JOIN with an empty keyset and a non-equi condition 
(probe.id < build.rt). A
+        // probe that matches no build row in the group is emitted null-padded.
+        LateralSnapshotJoinOperator op =
+                newOperator(true, newProbeIdLtBuildRtCondition(), new 
boolean[0], 100L, null, null);
+        try (KeyedTwoInputStreamOperatorTestHarness<RowData, RowData, RowData, 
RowData> h =
+                newEmptyKeyHarness(op)) {
+            h.open();
+            addBuildChange(h, insertRecord("k1", "v1", 10L));
+            addBuildChange(h, insertRecord("k2", "v2", 20L));
+
+            // LOAD phase: buffered probe id=100 matches no build row -> 
null-padded at the flip.
+            addProbeRecord(h, 100L, "px", "p1");
+            addProbeWm(h, 80L); // lets the flip drain the buffered probe
+            addBuildWm(h, 100L); // flip to JOIN
+            assertPhase(op, Phase.JOIN);
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(h, row(100L, "px", "p1", null, null, 
null));
+
+            // JOIN phase: id=5 matches k1 and k2; id=18 matches k2; id=25 
matches none
+            // (null-padded).
+            addProbeRecord(h, 5L, "py", "p2");
+            addProbeRecord(h, 18L, "pz", "p3");
+            addProbeRecord(h, 25L, "pzz", "p4");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(
+                    h,
+                    row(5L, "py", "p2", "k1", "v1", 10L),
+                    row(5L, "py", "p2", "k2", "v2", 20L),
+                    row(18L, "pz", "p3", "k2", "v2", 20L),
+                    row(25L, "pzz", "p4", null, null, null));
+        }
+    }
+
+    @Test
+    void emptyKeyModeLeftOuterReflectsBuildInsertAndDelete() throws Exception {
+        // LEFT OUTER with an empty keyset: a matching build row is added and 
later removed, so the
+        // same probe sees null (no match), then non-null (match), then null 
(match removed).
+        LateralSnapshotJoinOperator op =
+                newOperator(true, newProbeIdLtBuildRtCondition(), new 
boolean[0], 100L, null, null);
+        try (KeyedTwoInputStreamOperatorTestHarness<RowData, RowData, RowData, 
RowData> h =
+                newEmptyKeyHarness(op)) {
+            h.open();
+            addBuildWm(h, 100L); // flip to JOIN with an empty build table
+            assertPhase(op, Phase.JOIN);
+
+            // (1) No matching build row yet -> null-padded.
+            addProbeRecord(h, 5L, "p", "p1");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(h, row(5L, "p", "p1", null, null, 
null));
+
+            // Add a matching build row (probe id 5 < build rt 110) and drain 
it.
+            addBuildChange(h, insertRecord("k1", "v1", 110L));
+            addBuildWm(h, 111L);
+            // (2) Probe now matches -> non-null.
+            addProbeRecord(h, 5L, "p", "p2");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(h, row(5L, "p", "p2", "k1", "v1", 
110L));
+
+            // Remove the build row again (retraction matches the inserted 
row, incl. row-time) and
+            // drain it.
+            addBuildChange(h, deleteRecord("k1", "v1", 110L));
+            addBuildWm(h, 121L);
+            // (3) Probe matches nothing again -> null-padded.
+            addProbeRecord(h, 5L, "p", "p3");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(h, row(5L, "p", "p3", null, null, 
null));
+        }
+    }
+
     @Test
     void joinPhaseProbeIsBufferedWhileKeyStillDraining() throws Exception {
         // Flip without a probe watermark leaves the LOAD-buffered probe in 
place. A new JOIN-phase
@@ -1415,6 +1559,39 @@ class LateralSnapshotJoinOperatorTest {
         }
     }
 
+    @Test
+    void emptyKeyModeStateTtlEvictsEntireBuildTable() throws Exception {
+        // With no equi-key all build rows share one (empty) key, so a single 
state_ttl timer covers
+        // the whole build side. After an idle period longer than the TTL, the 
entire build table is
+        // evicted at once; a later probe then matches nothing (here: LEFT -> 
null-padded).
+        LateralSnapshotJoinOperator op =
+                newOperator(true, newProbeIdLtBuildRtCondition(), new 
boolean[0], 100L, null, 100L);
+        try (KeyedTwoInputStreamOperatorTestHarness<RowData, RowData, RowData, 
RowData> h =
+                newEmptyKeyHarness(op)) {
+            h.setProcessingTime(0);
+            h.open();
+            addBuildChange(h, insertRecord("k1", "v1", 10L));
+            addBuildChange(h, insertRecord("k2", "v2", 20L));
+            addBuildWm(h, 100L); // flip to JOIN
+            assertPhase(op, Phase.JOIN);
+
+            // Probe id=5 matches both build rows (5 < 10, 5 < 20).
+            addProbeRecord(h, 5L, "p", "p1");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(
+                    h, row(5L, "p", "p1", "k1", "v1", 10L), row(5L, "p", "p1", 
"k2", "v2", 20L));
+
+            // Idle well past the TTL: the single empty-key timer evicts the 
whole build table.
+            h.setProcessingTime(1000);
+
+            // The same probe now matches nothing -> null-padded, confirming 
the whole table was
+            // evicted together.
+            addProbeRecord(h, 5L, "p", "p2");
+            stripWatermarksAndStatusesFromOutput(h);
+            JOINED_ASSERTOR.shouldEmitAll(h, row(5L, "p", "p2", null, null, 
null));
+        }
+    }
+
     // ----------------------------------------------------------------- 
Snapshot / restore
 
     @ParameterizedTest(name = "backend={0}")

Reply via email to