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

twalthr 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 4732a5210a8 [FLINK-40111][table-planner] Fall back to retract for a 
keyless sink without an upsert key
4732a5210a8 is described below

commit 4732a5210a876a55a9367e31f3aa9f8cf0f45fd7
Author: Gustavo de Morais <[email protected]>
AuthorDate: Fri Jul 10 09:26:54 2026 +0200

    [FLINK-40111][table-planner] Fall back to retract for a keyless sink 
without an upsert key
    
    This closes #28704.
---
 .../FlinkChangelogModeInferenceProgram.scala       | 10 +++--
 .../physical/stream/ChangelogModeInferenceTest.xml | 52 ++++++++++++++++++++++
 .../plan/stream/sql/DagOptimizationTest.xml        | 12 ++---
 .../stream/ChangelogModeInferenceTest.scala        | 37 +++++++++++++++
 .../plan/stream/sql/DagOptimizationTest.scala      |  8 ++--
 5 files changed, 107 insertions(+), 12 deletions(-)

diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
index 77fc7208d24..8313e4c1d96 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/optimize/program/FlinkChangelogModeInferenceProgram.scala
@@ -1074,15 +1074,19 @@ class FlinkChangelogModeInferenceProgram extends 
FlinkOptimizeProgram[StreamOpti
      * <p>Notice: even if sink pk is a subset of the upsert key, the pk is NOT 
considered satisfied
      * when the upsert key has columns outside sink pk. This differs from 
batch job's unique key
      * inference.
+     *
+     * <p>A sink without a primary key is satisfied whenever the input carries 
any upsert key.
      */
     private def canUpsertKeysWithImmutableColsSatisfyPk(sink: 
StreamPhysicalSink): Boolean = {
       val sinkDefinedPks = 
sink.contextResolvedTable.getResolvedSchema.getPrimaryKeyIndexes
+      val fmq = 
FlinkRelMetadataQuery.reuseOrCreate(sink.getCluster.getMetadataQuery)
+      val changeLogUpsertKeys = fmq.getUpsertKeys(sink.getInput)
       if (sinkDefinedPks.isEmpty) {
-        return true
+        // A keyless sink cannot apply UPDATE_AFTER in place, so it can only 
accept upsert when the
+        // input itself carries an upsert key; otherwise fall back to 
beforeAndAfter.
+        return changeLogUpsertKeys != null && !changeLogUpsertKeys.isEmpty
       }
       val sinkPks = ImmutableBitSet.of(sinkDefinedPks: _*)
-      val fmq = 
FlinkRelMetadataQuery.reuseOrCreate(sink.getCluster.getMetadataQuery)
-      val changeLogUpsertKeys = fmq.getUpsertKeys(sink.getInput)
       // if upsert key is null, pk cannot be satisfied, should fall back to 
beforeAndAfter
       if (changeLogUpsertKeys == null) {
         return false
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.xml
index 03c86424dd1..1e453bc0c9e 100644
--- 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.xml
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.xml
@@ -172,6 +172,58 @@ GroupAggregate(groupBy=[cnt], select=[cnt, 
COUNT_RETRACT(cnt) AS frequency], cha
       :        +- LegacyTableSourceScan(table=[[default_catalog, 
default_database, MyTable, source: [CollectionTableSource(word, number)]]], 
fields=[word, number], changelogMode=[I])
       +- Calc(select=[CAST(cnt AS BIGINT) AS cnt], changelogMode=[I])
          +- LegacyTableSourceScan(table=[[default_catalog, default_database, 
MyTable2, source: [CollectionTableSource(word, cnt)]]], fields=[word, cnt], 
changelogMode=[I])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase 
name="testKeylessUpsertSinkFallsBackToRetractWhenUpsertKeyProjectedAway">
+    <Resource name="sql">
+      <![CDATA[INSERT INTO keyless_upsert_sink_no_key SELECT rate FROM 
DeduplicatedView]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalSink(table=[default_catalog.default_database.keyless_upsert_sink_no_key],
 fields=[rate])
++- LogicalProject(rate=[$1])
+   +- LogicalProject(currency=[$0], rate=[$1], rowtime=[$2])
+      +- LogicalFilter(condition=[=($3, 1)])
+         +- LogicalProject(currency=[$0], rate=[$1], rowtime=[$2], 
rowNum=[ROW_NUMBER() OVER (PARTITION BY $0 ORDER BY $2 DESC NULLS LAST)])
+            +- LogicalWatermarkAssigner(rowtime=[rowtime], watermark=[$2])
+               +- LogicalTableScan(table=[[default_catalog, default_database, 
ratesHistory, source: [CollectionTableSource(currency, rate, rowtime)]]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+Sink(table=[default_catalog.default_database.keyless_upsert_sink_no_key], 
fields=[rate], changelogMode=[NONE])
++- Calc(select=[rate], changelogMode=[I,UB,UA,D])
+   +- Rank(strategy=[AppendFastStrategy], rankType=[ROW_NUMBER], 
rankRange=[rankStart=1, rankEnd=1], partitionBy=[currency], orderBy=[ROWTIME 
rowtime DESC], select=[currency, rate, rowtime], changelogMode=[I,UB,UA,D])
+      +- Exchange(distribution=[hash[currency]], changelogMode=[I])
+         +- WatermarkAssigner(rowtime=[rowtime], watermark=[rowtime], 
changelogMode=[I])
+            +- LegacyTableSourceScan(table=[[default_catalog, 
default_database, ratesHistory, source: [CollectionTableSource(currency, rate, 
rowtime)]]], fields=[currency, rate, rowtime], changelogMode=[I])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testKeylessUpsertSinkKeepsUpsertWhenInputHasUpsertKey">
+    <Resource name="sql">
+      <![CDATA[INSERT INTO keyless_upsert_sink SELECT currency, rate FROM 
DeduplicatedView]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalSink(table=[default_catalog.default_database.keyless_upsert_sink], 
fields=[currency, rate])
++- LogicalProject(currency=[$0], rate=[$1])
+   +- LogicalProject(currency=[$0], rate=[$1], rowtime=[$2])
+      +- LogicalFilter(condition=[=($3, 1)])
+         +- LogicalProject(currency=[$0], rate=[$1], rowtime=[$2], 
rowNum=[ROW_NUMBER() OVER (PARTITION BY $0 ORDER BY $2 DESC NULLS LAST)])
+            +- LogicalWatermarkAssigner(rowtime=[rowtime], watermark=[$2])
+               +- LogicalTableScan(table=[[default_catalog, default_database, 
ratesHistory, source: [CollectionTableSource(currency, rate, rowtime)]]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+Sink(table=[default_catalog.default_database.keyless_upsert_sink], 
fields=[currency, rate], changelogMode=[NONE])
++- Calc(select=[currency, rate], changelogMode=[I,UA,D])
+   +- Rank(strategy=[AppendFastStrategy], rankType=[ROW_NUMBER], 
rankRange=[rankStart=1, rankEnd=1], partitionBy=[currency], orderBy=[ROWTIME 
rowtime DESC], select=[currency, rate, rowtime], changelogMode=[I,UA,D])
+      +- Exchange(distribution=[hash[currency]], changelogMode=[I])
+         +- WatermarkAssigner(rowtime=[rowtime], watermark=[rowtime], 
changelogMode=[I])
+            +- LegacyTableSourceScan(table=[[default_catalog, 
default_database, ratesHistory, source: [CollectionTableSource(currency, rate, 
rowtime)]]], fields=[currency, rate, rowtime], changelogMode=[I])
 ]]>
     </Resource>
   </TestCase>
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.xml
index 9f7107c8013..67d54af4a83 100644
--- 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.xml
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.xml
@@ -994,7 +994,7 @@ Sink(table=[default_catalog.default_database.appendSink], 
fields=[a, b, c, d, e,
   <TestCase name="testUnionAndAggWithDifferentGroupings">
     <Resource name="ast">
       <![CDATA[
-LogicalSink(table=[default_catalog.default_database.upsertSink], fields=[b, c, 
a_sum])
+LogicalSink(table=[default_catalog.default_database.retractSink], fields=[b, 
c, a_sum])
 +- LogicalUnion(all=[true])
    :- LogicalAggregate(group=[{0, 1}], a_sum=[SUM($2)])
    :  +- LogicalProject(b=[$1], c=[$2], a=[$0])
@@ -1007,13 +1007,13 @@ 
LogicalSink(table=[default_catalog.default_database.upsertSink], fields=[b, c, a
     </Resource>
     <Resource name="optimized rel plan">
       <![CDATA[
-Sink(table=[default_catalog.default_database.upsertSink], fields=[b, c, 
a_sum], changelogMode=[NONE])
-+- Union(all=[true], union=[b, c, a_sum], changelogMode=[I,UA])
-   :- GroupAggregate(groupBy=[b, c], select=[b, c, SUM(a) AS a_sum], 
changelogMode=[I,UA])
+Sink(table=[default_catalog.default_database.retractSink], fields=[b, c, 
a_sum], changelogMode=[NONE])
++- Union(all=[true], union=[b, c, a_sum], changelogMode=[I,UB,UA])
+   :- GroupAggregate(groupBy=[b, c], select=[b, c, SUM(a) AS a_sum], 
changelogMode=[I,UB,UA])
    :  +- Exchange(distribution=[hash[b, c]], changelogMode=[I])
    :     +- TableSourceScan(table=[[default_catalog, default_database, 
MyTable]], fields=[a, b, c], changelogMode=[I])
-   +- Calc(select=[CAST(1 AS BIGINT) AS b, c, a_sum], changelogMode=[I,UA])
-      +- GroupAggregate(groupBy=[c], select=[c, SUM(a) AS a_sum], 
changelogMode=[I,UA])
+   +- Calc(select=[CAST(1 AS BIGINT) AS b, c, a_sum], changelogMode=[I,UB,UA])
+      +- GroupAggregate(groupBy=[c], select=[c, SUM(a) AS a_sum], 
changelogMode=[I,UB,UA])
          +- Exchange(distribution=[hash[c]], changelogMode=[I])
             +- Calc(select=[c, a], changelogMode=[I])
                +- TableSourceScan(table=[[default_catalog, default_database, 
MyTable]], fields=[a, b, c], changelogMode=[I])
diff --git 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.scala
 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.scala
index ce2dc3d4468..787cbf39116 100644
--- 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.scala
+++ 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/rules/physical/stream/ChangelogModeInferenceTest.scala
@@ -546,4 +546,41 @@ class ChangelogModeInferenceTest extends TableTestBase {
     // upsert key {id, name} is NOT a subset of sink pk {id}, so 
BEFORE_AND_AFTER is required
     util.verifyRelPlan(statementSet, ExplainDetail.CHANGELOG_MODE)
   }
+
+  @Test
+  def testKeylessUpsertSinkKeepsUpsertWhenInputHasUpsertKey(): Unit = {
+    // Input keeps its upsert key (currency), so a keyless upsert-only sink 
stays upsert.
+    util.addTable("""
+                    |CREATE TABLE keyless_upsert_sink (
+                    |  currency STRING,
+                    |  rate INT
+                    |) WITH (
+                    |  'connector' = 'values',
+                    |  'sink-insert-only' = 'false',
+                    |  'sink-changelog-mode-enforced' = 'I,UA,D'
+                    |)
+                    |""".stripMargin)
+    util.verifyRelPlanInsert(
+      "INSERT INTO keyless_upsert_sink SELECT currency, rate FROM 
DeduplicatedView",
+      ExplainDetail.CHANGELOG_MODE)
+  }
+
+  @Test
+  def testKeylessUpsertSinkFallsBackToRetractWhenUpsertKeyProjectedAway(): 
Unit = {
+    // The input upsert key (currency) is projected away, so a keyless 
upsert-only sink cannot
+    // upsert and the planner falls back to retract; the deduplicate's 
ChangelogNormalize supplies
+    // UPDATE_BEFORE.
+    util.addTable("""
+                    |CREATE TABLE keyless_upsert_sink_no_key (
+                    |  rate INT
+                    |) WITH (
+                    |  'connector' = 'values',
+                    |  'sink-insert-only' = 'false',
+                    |  'sink-changelog-mode-enforced' = 'I,UA,D'
+                    |)
+                    |""".stripMargin)
+    util.verifyRelPlanInsert(
+      "INSERT INTO keyless_upsert_sink_no_key SELECT rate FROM 
DeduplicatedView",
+      ExplainDetail.CHANGELOG_MODE)
+  }
 }
diff --git 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.scala
 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.scala
index 5430545af23..0daee92964e 100644
--- 
a/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.scala
+++ 
b/flink-table/flink-table-planner/src/test/scala/org/apache/flink/table/planner/plan/stream/sql/DagOptimizationTest.scala
@@ -573,14 +573,16 @@ class DagOptimizationTest extends TableTestBase {
       """.stripMargin
     val table = util.tableEnv.sqlQuery(sqlQuery)
 
+    // The union of two differently-grouped aggregates has no common upsert 
key, so it feeds a
+    // retract sink.
     TestSinkUtil.addValuesSink(
       util.tableEnv,
-      "upsertSink",
+      "retractSink",
       List("b", "c", "a_sum"),
       List(LONG, STRING, INT),
-      ChangelogMode.upsert()
+      ChangelogMode.all()
     )
-    util.verifyRelPlanInsert(table, "upsertSink", ExplainDetail.CHANGELOG_MODE)
+    util.verifyRelPlanInsert(table, "retractSink", 
ExplainDetail.CHANGELOG_MODE)
   }
 
   @Test

Reply via email to