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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12967-41679ddc46117c09f42a2c9229e9148f1a56d065
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 76e11cb80e36b4844ba6820d43e0522dbd912d3c
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Tue Oct 27 03:49:13 2026 +0000

    [Fix][Format] Produce no row for a kafka connect json tombstone (#12967)
---
 .../formats/kafka-compatible-kafkaconnect-json.md  |   4 +
 docs/en/connectors/source/Kafka.md                 |  11 +-
 .../formats/kafka-compatible-kafkaconnect-json.md  |   3 +
 docs/zh/connectors/source/Kafka.md                 |   7 +-
 ...ompatibleKafkaConnectDeserializationSchema.java |   7 +
 ...tibleKafkaConnectDeserializationSchemaTest.java | 141 +++++++++++++++++++++
 6 files changed, 165 insertions(+), 8 deletions(-)

diff --git a/docs/en/connectors/formats/kafka-compatible-kafkaconnect-json.md 
b/docs/en/connectors/formats/kafka-compatible-kafkaconnect-json.md
index 97c4052d88..6054b42e95 100644
--- a/docs/en/connectors/formats/kafka-compatible-kafkaconnect-json.md
+++ b/docs/en/connectors/formats/kafka-compatible-kafkaconnect-json.md
@@ -6,6 +6,10 @@ Seatunnel connector kafka supports parsing data extracted 
through kafka connect
 
 UPDATE messages must contain a complete row image; partial updates containing 
only changed fields are not supported.
 
+A tombstone, a record that carries a key and a `null` value, produces no row. 
The source passes it over and the
+offset advances, so it is not treated as a parse error. A record whose value 
is present but zero length is a
+different case and is still a decode error.
+
 # How To Use
 
 ## Kafka Sink Mysql
diff --git a/docs/en/connectors/source/Kafka.md 
b/docs/en/connectors/source/Kafka.md
index 4d4e1183a2..a42a18ce82 100644
--- a/docs/en/connectors/source/Kafka.md
+++ b/docs/en/connectors/source/Kafka.md
@@ -617,11 +617,12 @@ Note:key/value is of type byte[].
 ### Tombstone records
 
 A tombstone is a Kafka record that has a key and no value, which compacted 
topics use to mark a
-key as deleted. For the `json`, `text`, `canal_json`, `debezium_json`, 
`ogg_json`, `maxwell_json`
-and `avro` formats the source reads a tombstone as a record that produces no 
row: it is not a
-format error, so it is passed over whatever `format_error_handle_way` is set 
to, and the offset
-moves on past it. The `text`, `canal_json`, `debezium_json`, `ogg_json` and 
`avro` formats treat
-an empty (zero length) value the same way; `json` and `maxwell_json` pass over 
a null value only.
+key as deleted. For the `json`, `text`, `canal_json`, `debezium_json`, 
`ogg_json`, `maxwell_json`,
+`avro` and `compatible_kafka_connect_json` formats the source reads a 
tombstone as a record that
+produces no row: it is not a format error, so it is passed over whatever 
`format_error_handle_way`
+is set to, and the offset moves on past it. The `text`, `canal_json`, 
`debezium_json`, `ogg_json`
+and `avro` formats treat an empty (zero length) value the same way; `json`, 
`maxwell_json` and
+`compatible_kafka_connect_json` pass over a null value only.
 
 The `protobuf` format has no such guard and is not covered by this behaviour.
 
diff --git a/docs/zh/connectors/formats/kafka-compatible-kafkaconnect-json.md 
b/docs/zh/connectors/formats/kafka-compatible-kafkaconnect-json.md
index a50f8039c3..421c3a149c 100644
--- a/docs/zh/connectors/formats/kafka-compatible-kafkaconnect-json.md
+++ b/docs/zh/connectors/formats/kafka-compatible-kafkaconnect-json.md
@@ -6,6 +6,9 @@ Seatunnel 的 Kafka 连接器支持解析通过 Kafka Connect Source 抽取的
 
 更新消息必须包含完整行数据;仅包含变更字段的部分更新暂不支持。
 
+墓碑消息(tombstone,即带有键且值为 `null` 的记录)不会产生任何行。Source 会跳过该记录并推进 offset,因此它不会被视为解析错误。
+值存在但长度为 0 的记录属于另一种情况,仍然是解码错误。
+
 # 如何使用
 
 ## Kafka 流入 Mysql
diff --git a/docs/zh/connectors/source/Kafka.md 
b/docs/zh/connectors/source/Kafka.md
index baf14efa39..e238206d2e 100644
--- a/docs/zh/connectors/source/Kafka.md
+++ b/docs/zh/connectors/source/Kafka.md
@@ -607,9 +607,10 @@ source {
 ### 墓碑消息(tombstone)
 
 墓碑消息是只有 key、没有 value 的 Kafka 记录,压缩主题用它标记某个 key 已被删除。对于 
`json`、`text`、`canal_json`、
-`debezium_json`、`ogg_json`、`maxwell_json` 和 `avro` 
格式,源端把墓碑消息读取为不产生任何数据行的记录:它不属于格式错误,因此无论
-`format_error_handle_way` 取何值都会被跳过,并且位点会继续向前推进。其中 
`text`、`canal_json`、`debezium_json`、`ogg_json`
-和 `avro` 对空(长度为 0)的 value 也做同样处理;`json` 和 `maxwell_json` 仅跳过 null value。
+`debezium_json`、`ogg_json`、`maxwell_json`、`avro` 和 
`compatible_kafka_connect_json` 格式,源端把墓碑消息读取为不产生
+任何数据行的记录:它不属于格式错误,因此无论 `format_error_handle_way` 取何值都会被跳过,并且位点会继续向前推进。其中 
`text`、
+`canal_json`、`debezium_json`、`ogg_json` 和 `avro` 对空(长度为 0)的 value 
也做同样处理;`json`、`maxwell_json` 和
+`compatible_kafka_connect_json` 仅跳过 null value。
 
 `protobuf` 格式没有这样的空值判断,不适用上述行为。
 
diff --git 
a/seatunnel-formats/seatunnel-format-compatible-connect-json/src/main/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchema.java
 
b/seatunnel-formats/seatunnel-format-compatible-connect-json/src/main/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchema.java
index 0a9e8867ea..1b55d09f46 100644
--- 
a/seatunnel-formats/seatunnel-format-compatible-connect-json/src/main/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchema.java
+++ 
b/seatunnel-formats/seatunnel-format-compatible-connect-json/src/main/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchema.java
@@ -110,6 +110,13 @@ public class CompatibleKafkaConnectDeserializationSchema
         if (msg == null) {
             return;
         }
+        if (msg.value() == null) {
+            // A Kafka tombstone carries a null value to mark its key deleted. 
That is a
+            // deletion marker rather than a malformed message, so it produces 
no row and the
+            // source moves past it, as the json and maxwell_json formats 
already do. A zero
+            // length payload is deliberately not covered: it is a decode 
error here, as before.
+            return;
+        }
         SinkRecord record = convertToSinkRecord(msg);
         RowKind rowKind = RowKind.INSERT;
         JsonNode jsonNode =
diff --git 
a/seatunnel-formats/seatunnel-format-compatible-connect-json/src/test/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchemaTest.java
 
b/seatunnel-formats/seatunnel-format-compatible-connect-json/src/test/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchemaTest.java
new file mode 100644
index 0000000000..db9c3de7f5
--- /dev/null
+++ 
b/seatunnel-formats/seatunnel-format-compatible-connect-json/src/test/java/org/apache/seatunnel/format/compatible/kafka/connect/json/CompatibleKafkaConnectDeserializationSchemaTest.java
@@ -0,0 +1,141 @@
+/*
+ * 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.seatunnel.format.compatible.kafka.connect.json;
+
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
+import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.record.TimestampType;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+/** Tombstone handling for the {@code compatible_kafka_connect_json} source 
format. */
+class CompatibleKafkaConnectDeserializationSchemaTest {
+
+    /**
+     * The converters are configured with {@code schemas.enable}, whose option 
default is true, so
+     * both the key and the value of a well formed record carry a 
schema/payload envelope.
+     */
+    private static final String KEY_ENVELOPE =
+            
"{\"schema\":{\"type\":\"string\",\"optional\":true},\"payload\":\"k1\"}";
+
+    private static final String VALUE_ENVELOPE =
+            "{\"schema\":{\"type\":\"struct\",\"fields\":["
+                    + 
"{\"type\":\"int64\",\"optional\":true,\"field\":\"id\"},"
+                    + 
"{\"type\":\"string\",\"optional\":true,\"field\":\"name\"}]},"
+                    + "\"payload\":{\"id\":1,\"name\":\"a\"}}";
+
+    /** A tombstone carries a null value, so it must produce no row rather 
than failing. */
+    @Test
+    void deserializeProducesNoRowForATombstone() throws Exception {
+        RecordingCollector collector = new RecordingCollector();
+        schema().deserialize(record(null), collector);
+
+        Assertions.assertTrue(
+                collector.rows.isEmpty(), "a tombstone must not produce a row 
of its own");
+    }
+
+    /** The guard must not swallow ordinary records. */
+    @Test
+    void deserializeStillReadsAWellFormedRecord() throws Exception {
+        RecordingCollector collector = new RecordingCollector();
+        
schema().deserialize(record(VALUE_ENVELOPE.getBytes(StandardCharsets.UTF_8)), 
collector);
+
+        Assertions.assertEquals(1, collector.rows.size(), "a valid record must 
still be read");
+        SeaTunnelRow row = collector.rows.get(0);
+        Assertions.assertEquals(1L, row.getField(0));
+        Assertions.assertEquals("a", row.getField(1));
+    }
+
+    /**
+     * A zero length value is a decode error rather than a deletion marker, so 
the guard must not
+     * pass it over. Asserted against the contract rather than against a 
specific exception type,
+     * which comes from the Kafka converter.
+     */
+    @Test
+    void deserializeDoesNotTreatAnEmptyValueAsATombstone() {
+        RecordingCollector collector = new RecordingCollector();
+
+        Assertions.assertThrows(
+                Exception.class,
+                () -> schema().deserialize(record(new byte[0]), collector),
+                "a zero length value must stay a decode error, not be read as 
a tombstone");
+        Assertions.assertTrue(
+                collector.rows.isEmpty(), "a rejected record must not have 
produced a row");
+    }
+
+    private static CompatibleKafkaConnectDeserializationSchema schema() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"id", "name"},
+                        new SeaTunnelDataType[] {BasicType.LONG_TYPE, 
BasicType.STRING_TYPE});
+        CatalogTable catalogTable = 
CatalogTableUtil.getCatalogTable("kafka_connect_json", rowType);
+        // The same flags KafkaSourceConfig passes, both of which default to 
true.
+        return new CompatibleKafkaConnectDeserializationSchema(
+                catalogTable, true, true, false, false);
+    }
+
+    private static ConsumerRecord<byte[], byte[]> record(byte[] value) {
+        return new ConsumerRecord<>(
+                "topic",
+                0,
+                42L,
+                1234L,
+                TimestampType.CREATE_TIME,
+                0,
+                0,
+                KEY_ENVELOPE.getBytes(StandardCharsets.UTF_8),
+                value,
+                new RecordHeaders(),
+                Optional.empty());
+    }
+
+    private static class RecordingCollector implements Collector<SeaTunnelRow> 
{
+        private final List<SeaTunnelRow> rows = new ArrayList<>();
+
+        @Override
+        public void collect(SeaTunnelRow record) {
+            rows.add(record);
+        }
+
+        @Override
+        public void collect(SchemaChangeEvent event) {}
+
+        @Override
+        public void markSchemaChangeBeforeCheckpoint() {}
+
+        @Override
+        public Object getCheckpointLock() {
+            return this;
+        }
+    }
+}

Reply via email to