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 2558209c55 [cdc] Pair canal UPDATE rows by position instead of by 
content (#10370)
2558209c55 is described below

commit 2558209c5525c17d585ec2faee9201a572c4e758
Author: jackylee <[email protected]>
AuthorDate: Mon Oct 5 20:45:16 2026 +0800

    [cdc] Pair canal UPDATE rows by position instead of by content (#10370)
---
 .../action/cdc/format/canal/CanalRecordParser.java | 18 ++---
 .../cdc/format/canal/CanalRecordParserTest.java    | 84 ++++++++++++++++++++++
 2 files changed, 90 insertions(+), 12 deletions(-)

diff --git 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParser.java
 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParser.java
index 21d5935aac..034e6734dd 100644
--- 
a/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParser.java
+++ 
b/paimon-flink/paimon-flink-cdc/src/main/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParser.java
@@ -46,8 +46,6 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
 
 import static org.apache.paimon.utils.JsonSerdeUtil.getNodeAs;
 import static org.apache.paimon.utils.JsonSerdeUtil.isNull;
@@ -101,15 +99,17 @@ public class CanalRecordParser extends 
AbstractJsonRecordParser {
 
         String type = getAndCheck(FIELD_TYPE).asText();
 
-        for (JsonNode data : arrayData) {
+        for (int i = 0; i < arrayData.size(); i++) {
+            JsonNode data = arrayData.get(i);
             switch (type) {
                 case OP_UPDATE:
                     ArrayNode oldArrayData = getNodeAs(root, FIELD_OLD, 
ArrayNode.class);
                     checkNotNull(oldArrayData, FIELD_OLD, FIELD_TYPE, type);
 
-                    Map<JsonNode, JsonNode> matchedOldRecords =
-                            matchOldRecords(arrayData, oldArrayData);
-                    JsonNode old = matchedOldRecords.get(data);
+                    // Pair the i-th new row with the i-th old row by 
position. The previous
+                    // content-keyed lookup threw IllegalStateException on a 
batch that held two
+                    // equal row images (natural for a table without a primary 
key).
+                    JsonNode old = oldArrayData.get(i);
                     processRecord(mergeOldRecord(data, old), RowKind.DELETE, 
records);
                     processRecord(data, RowKind.INSERT, records);
                     break;
@@ -190,12 +190,6 @@ public class CanalRecordParser extends 
AbstractJsonRecordParser {
         return "canal-json";
     }
 
-    private Map<JsonNode, JsonNode> matchOldRecords(ArrayNode newData, 
ArrayNode oldData) {
-        return IntStream.range(0, newData.size())
-                .boxed()
-                .collect(Collectors.toMap(newData::get, oldData::get));
-    }
-
     private String transformValue(@Nullable String oldValue, String shortType, 
String mySqlType) {
         if (oldValue == null) {
             return null;
diff --git 
a/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParserTest.java
 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParserTest.java
new file mode 100644
index 0000000000..3447ba240d
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-cdc/src/test/java/org/apache/paimon/flink/action/cdc/format/canal/CanalRecordParserTest.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.action.cdc.format.canal;
+
+import org.apache.paimon.flink.action.cdc.CdcSourceRecord;
+import org.apache.paimon.flink.action.cdc.TypeMapping;
+import org.apache.paimon.flink.sink.cdc.RichCdcMultiplexRecord;
+import org.apache.paimon.types.RowKind;
+
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import 
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Unit tests for {@link CanalRecordParser}. */
+public class CanalRecordParserTest {
+
+    /**
+     * A Canal {@code UPDATE} event may carry two equal row images in one 
batch (natural for a table
+     * without a primary key, or a bulk update that sets several rows to the 
same values). The old
+     * positional pairing was built with {@code Collectors.toMap} keyed by the 
row node, so equal
+     * images collided and the parser threw {@code IllegalStateException: 
Duplicate key}. Pairing by
+     * position must instead emit a DELETE + INSERT for every row.
+     */
+    @Test
+    public void testUpdateWithDuplicateRowImagesPairsByPosition() throws 
Exception {
+        String json =
+                "{"
+                        + "\"database\":\"test_db\",\"table\":\"test_table\","
+                        + 
"\"pkNames\":[\"id\"],\"isDdl\":false,\"type\":\"UPDATE\","
+                        + 
"\"mysqlType\":{\"id\":\"int\",\"name\":\"varchar(20)\"},"
+                        + 
"\"data\":[{\"id\":\"1\",\"name\":\"x\"},{\"id\":\"1\",\"name\":\"x\"}],"
+                        + "\"old\":[{\"name\":\"a\"},{\"name\":\"b\"}]"
+                        + "}";
+
+        CanalRecordParser parser =
+                new CanalRecordParser(TypeMapping.defaultMapping(), 
Collections.emptyList());
+        JsonNode rootNode = new ObjectMapper().readValue(json, JsonNode.class);
+        CdcSourceRecord cdcRecord = new CdcSourceRecord(rootNode);
+        parser.buildSchema(cdcRecord);
+
+        List<RichCdcMultiplexRecord> records = parser.extractRecords();
+
+        // Two updated rows, each emits a DELETE (old image) + an INSERT (new 
image).
+        assertThat(records).hasSize(4);
+        assertThat(
+                        records.stream()
+                                .filter(
+                                        r ->
+                                                
r.toRichCdcRecord().toCdcRecord().kind()
+                                                        == RowKind.DELETE)
+                                .count())
+                .isEqualTo(2);
+        assertThat(
+                        records.stream()
+                                .filter(
+                                        r ->
+                                                
r.toRichCdcRecord().toCdcRecord().kind()
+                                                        == RowKind.INSERT)
+                                .count())
+                .isEqualTo(2);
+    }
+}

Reply via email to