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

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


The following commit(s) were added to refs/heads/dev by this push:
     new 1e450e11f7 [Fix][Connector-V2][AmazonSqs] Preserve messages on 
deserialization failure (#12038)
1e450e11f7 is described below

commit 1e450e11f7c8e3ef78d186b0a407148d5faed939
Author: Goutam Adwant <[email protected]>
AuthorDate: Fri Sep 4 02:53:49 2026 +0000

    [Fix][Connector-V2][AmazonSqs] Preserve messages on deserialization failure 
(#12038)
---
 docs/en/connectors/source/AmazonSqs.md             |   3 +
 docs/zh/connectors/source/AmazonSqs.md             |   3 +
 .../amazonsqs/config/AmazonSqsSourceConfig.java    |   3 +
 .../amazonsqs/config/AmazonSqsSourceOptions.java   |   6 +
 .../deserialize/AmazonSqsDeserializer.java         |  23 +-
 .../AmazonSqsConnectorErrorCode.java}              |  29 +-
 .../amazonsqs/source/AmazonSqsSourceFactory.java   |  14 +-
 .../amazonsqs/source/AmazonSqsSourceReader.java    |   8 +-
 .../amazonsqs/AmazonSqsSourceFactoryTest.java      |   6 +
 .../source/AmazonSqsSourceReaderTest.java          | 457 +++++++++++++++++++++
 10 files changed, 529 insertions(+), 23 deletions(-)

diff --git a/docs/en/connectors/source/AmazonSqs.md 
b/docs/en/connectors/source/AmazonSqs.md
index f571cb4551..443d8434ef 100644
--- a/docs/en/connectors/source/AmazonSqs.md
+++ b/docs/en/connectors/source/AmazonSqs.md
@@ -40,6 +40,7 @@ Each receive request asks SQS for up to 10 messages. If more 
messages are waitin
 | secret_access_key              | String  | No       | -       | AWS secret 
access key. Set it together with `access_key_id` to use static credentials.     
                                                                  |
 | format                         | String  | No       | json    | Message body 
format. Supported values are `json`, `text`, `canal_json`, and `debezium_json`. 
                                                               |
 | field_delimiter                | String  | No       | ,       | Field 
delimiter used when `format = text`.                                            
                                                                      |
+| ignore_parse_errors            | Boolean | No       | false   | Whether to 
skip messages that cannot be deserialized instead of failing the poll.          
                                                                  |
 | delete_message                 | Boolean | No       | false   | Whether to 
delete a message from the queue after it is read and deserialized successfully. 
                                                                  |
 | message_group_id               | String  | No       | -       | Message 
group ID option kept for compatibility. It is not required for normal SQS 
reads.                                                                     |
 | debezium_record_include_schema | Boolean | No       | true    | Whether 
Debezium JSON messages include a schema. This option is used only when `format 
= debezium_json`.                                                     |
@@ -53,6 +54,8 @@ Each receive request asks SQS for up to 10 messages. If more 
messages are waitin
 - `text` splits each message body by `field_delimiter` and maps the values to 
fields in `schema` order.
 - `canal_json` reads Canal JSON messages. For details, see [Canal 
JSON](../formats/canal-json.md).
 - `debezium_json` reads Debezium JSON messages. For details, see [Debezium 
JSON](../formats/debezium-json.md).
+- `ignore_parse_errors = false` fails the poll and retains an unreadable 
message. When set to `true`, the source skips the message and continues 
processing the batch.
+- When both `ignore_parse_errors` and `delete_message` are `true`, skipped 
messages are deleted from SQS. Keep `delete_message = false` if skipped 
messages should remain available for redelivery.
 - `delete_message = true` removes consumed messages from SQS. Keep the default 
`false` when you only want to inspect or copy messages without deleting them.
 - `access_key_id` and `secret_access_key` are optional, but they must be 
configured together when static AWS credentials are used.
 - The source performs one receive request, with up to 10 messages, and then 
finishes the bounded job.
diff --git a/docs/zh/connectors/source/AmazonSqs.md 
b/docs/zh/connectors/source/AmazonSqs.md
index 159d5f4ecc..81f2f7db59 100644
--- a/docs/zh/connectors/source/AmazonSqs.md
+++ b/docs/zh/connectors/source/AmazonSqs.md
@@ -39,6 +39,7 @@ Amazon SQS 源连接器用于从一个 Amazon SQS 队列 URL 读取消息。连
 | secret_access_key              | String  | 否    | -     | AWS secret access 
key。和 `access_key_id` 一起配置时使用静态凭证。                                              
                |
 | format                         | String  | 否    | json  | 消息体格式。支持 
`json`、`text`、`canal_json`、`debezium_json`。                                     
                          |
 | field_delimiter                | String  | 否    | ,     | 当 `format = text` 
时使用的字段分隔符。                                                                      
                   |
+| ignore_parse_errors            | Boolean | 否    | false | 
是否跳过无法解析的消息并继续处理,而不是让本次轮询失败。                                                    
                                     |
 | delete_message                 | Boolean | 否    | false | 
读取并成功解析消息后,是否从队列中删除该消息。                                                         
                                     |
 | message_group_id               | String  | 否    | -     | 为兼容保留的消息分组 ID 
选项,普通 SQS 读取不需要配置。                                                              
                         |
 | debezium_record_include_schema | Boolean | 否    | true  | Debezium JSON 
消息是否包含 schema。仅在 `format = debezium_json` 时使用。                                  
                      |
@@ -52,6 +53,8 @@ Amazon SQS 源连接器用于从一个 Amazon SQS 队列 URL 读取消息。连
 - `text`:按 `field_delimiter` 切分消息体,并按 `schema` 中字段顺序映射。
 - `canal_json`:读取 Canal JSON 消息,详见 [Canal JSON](../formats/canal-json.md)。
 - `debezium_json`:读取 Debezium JSON 消息,详见 [Debezium 
JSON](../formats/debezium-json.md)。
+- `ignore_parse_errors = false` 会让本次轮询失败并保留无法解析的消息。设置为 `true` 
时,源连接器会跳过该消息并继续处理本批次中的其他消息。
+- 当 `ignore_parse_errors` 和 `delete_message` 都为 `true` 时,跳过的消息会从 SQS 
中删除。如果需要保留这些消息以便重新投递,请保持 `delete_message = false`。
 - `delete_message = true` 会删除已经消费的 SQS 消息。如果只是检查或复制消息,建议保留默认值 `false`。
 - `access_key_id` 和 `secret_access_key` 是可选项;如果使用静态 AWS 凭证,需要两个一起配置。
 - 该源连接器只执行一次 receive 请求,最多读取 10 条消息,然后结束这个有界任务。
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceConfig.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceConfig.java
index 58113a0d91..9e51dc907f 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceConfig.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceConfig.java
@@ -42,6 +42,8 @@ public class AmazonSqsSourceConfig implements Serializable {
 
     private boolean deleteMessage;
 
+    private boolean ignoreParseErrors;
+
     private Config schema;
 
     public AmazonSqsSourceConfig(ReadonlyConfig config) {
@@ -51,6 +53,7 @@ public class AmazonSqsSourceConfig implements Serializable {
         this.secretAccessKey = 
config.get(AmazonSqsSourceOptions.SECRET_ACCESS_KEY);
         this.messageGroupId = 
config.get(AmazonSqsSourceOptions.MESSAGE_GROUP_ID);
         this.deleteMessage = config.get(AmazonSqsSourceOptions.DELETE_MESSAGE);
+        this.ignoreParseErrors = 
config.get(AmazonSqsSourceOptions.IGNORE_PARSE_ERRORS);
         this.schema = 
ReadonlyConfig.fromMap(config.get(AmazonSqsSourceOptions.SCHEMA)).toConfig();
         ;
     }
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceOptions.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceOptions.java
index d302f17661..d03acb2c6a 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceOptions.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/config/AmazonSqsSourceOptions.java
@@ -28,6 +28,12 @@ public class AmazonSqsSourceOptions extends 
AmazonSqsBaseOptions {
                     .defaultValue(false)
                     .withDescription("Delete the message after it is consumed 
if set true.");
 
+    public static final Option<Boolean> IGNORE_PARSE_ERRORS =
+            Options.key("ignore_parse_errors")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription("Optional flag to skip parse errors 
instead of failing.");
+
     public static final Option<String> MESSAGE_GROUP_ID =
             Options.key("message_group_id")
                     .stringType()
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
index 7322feda79..21314f20ed 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
@@ -19,23 +19,40 @@ package 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.deserialize;
 
 import org.apache.seatunnel.api.serialization.DeserializationSchema;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorErrorCode;
+import 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorException;
 
 import java.io.IOException;
 
 public class AmazonSqsDeserializer implements SeaTunnelRowDeserializer {
 
     private final DeserializationSchema<SeaTunnelRow> deserializationSchema;
+    private final boolean ignoreParseErrors;
 
-    public AmazonSqsDeserializer(DeserializationSchema<SeaTunnelRow> 
deserializationSchema) {
+    public AmazonSqsDeserializer(
+            DeserializationSchema<SeaTunnelRow> deserializationSchema, boolean 
ignoreParseErrors) {
         this.deserializationSchema = deserializationSchema;
+        this.ignoreParseErrors = ignoreParseErrors;
     }
 
     @Override
     public SeaTunnelRow deserializeRow(String row) {
         try {
-            return deserializationSchema.deserialize(row.getBytes());
+            SeaTunnelRow seaTunnelRow = 
deserializationSchema.deserialize(row.getBytes());
+            if (seaTunnelRow == null && !ignoreParseErrors) {
+                throw new AmazonSqsConnectorException(
+                        AmazonSqsConnectorErrorCode.DESERIALIZE_FAILED,
+                        "Failed to deserialize Amazon SQS message");
+            }
+            return seaTunnelRow;
         } catch (IOException e) {
-            return null;
+            if (ignoreParseErrors) {
+                return null;
+            }
+            throw new AmazonSqsConnectorException(
+                    AmazonSqsConnectorErrorCode.DESERIALIZE_FAILED,
+                    "Failed to deserialize Amazon SQS message",
+                    e);
         }
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/exception/AmazonSqsConnectorErrorCode.java
similarity index 53%
copy from 
seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
copy to 
seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/exception/AmazonSqsConnectorErrorCode.java
index 7322feda79..fa929814f4 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/exception/AmazonSqsConnectorErrorCode.java
@@ -15,27 +15,28 @@
  * limitations under the License.
  */
 
-package org.apache.seatunnel.connectors.seatunnel.amazonsqs.deserialize;
+package org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception;
 
-import org.apache.seatunnel.api.serialization.DeserializationSchema;
-import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.exception.SeaTunnelErrorCode;
 
-import java.io.IOException;
+public enum AmazonSqsConnectorErrorCode implements SeaTunnelErrorCode {
+    DESERIALIZE_FAILED("AmazonSqs-01", "Deserialize Amazon SQS message 
failed");
 
-public class AmazonSqsDeserializer implements SeaTunnelRowDeserializer {
+    private final String code;
+    private final String description;
 
-    private final DeserializationSchema<SeaTunnelRow> deserializationSchema;
+    AmazonSqsConnectorErrorCode(String code, String description) {
+        this.code = code;
+        this.description = description;
+    }
 
-    public AmazonSqsDeserializer(DeserializationSchema<SeaTunnelRow> 
deserializationSchema) {
-        this.deserializationSchema = deserializationSchema;
+    @Override
+    public String getCode() {
+        return code;
     }
 
     @Override
-    public SeaTunnelRow deserializeRow(String row) {
-        try {
-            return deserializationSchema.deserialize(row.getBytes());
-        } catch (IOException e) {
-            return null;
-        }
+    public String getDescription() {
+        return description;
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceFactory.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceFactory.java
index aaeda80396..3ce5fcaf6b 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceFactory.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceFactory.java
@@ -51,6 +51,7 @@ import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonS
 import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.DELETE_MESSAGE;
 import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.FIELD_DELIMITER;
 import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.FORMAT;
+import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.IGNORE_PARSE_ERRORS;
 import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.MESSAGE_GROUP_ID;
 import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.REGION;
 import static 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions.SECRET_ACCESS_KEY;
@@ -72,6 +73,7 @@ public class AmazonSqsSourceFactory implements 
TableSourceFactory {
                         SECRET_ACCESS_KEY,
                         MESSAGE_GROUP_ID,
                         DELETE_MESSAGE,
+                        IGNORE_PARSE_ERRORS,
                         FORMAT,
                         FIELD_DELIMITER,
                         DEBEZIUM_RECORD_INCLUDE_SCHEMA)
@@ -100,10 +102,13 @@ public class AmazonSqsSourceFactory implements 
TableSourceFactory {
     private DeserializationSchema<SeaTunnelRow> setDeserialization(
             Config config, CatalogTable catalogTable) {
         DeserializationSchema<SeaTunnelRow> deserializationSchema;
-        MessageFormat format = ReadonlyConfig.fromConfig(config).get(FORMAT);
+        ReadonlyConfig readonlyConfig = ReadonlyConfig.fromConfig(config);
+        MessageFormat format = readonlyConfig.get(FORMAT);
+        boolean ignoreParseErrors = readonlyConfig.get(IGNORE_PARSE_ERRORS);
         switch (format) {
             case JSON:
-                deserializationSchema = new 
JsonDeserializationSchema(catalogTable, false, false);
+                deserializationSchema =
+                        new JsonDeserializationSchema(catalogTable, false, 
ignoreParseErrors);
                 break;
             case TEXT:
                 String delimiter = DEFAULT_FIELD_DELIMITER;
@@ -119,7 +124,7 @@ public class AmazonSqsSourceFactory implements 
TableSourceFactory {
             case CANAL_JSON:
                 deserializationSchema =
                         CanalJsonDeserializationSchema.builder(catalogTable)
-                                .setIgnoreParseErrors(true)
+                                .setIgnoreParseErrors(ignoreParseErrors)
                                 .build();
                 break;
             case DEBEZIUM_JSON:
@@ -128,7 +133,8 @@ public class AmazonSqsSourceFactory implements 
TableSourceFactory {
                     includeSchema = 
config.getBoolean(DEBEZIUM_RECORD_INCLUDE_SCHEMA.key());
                 }
                 deserializationSchema =
-                        new DebeziumJsonDeserializationSchema(catalogTable, 
true, includeSchema);
+                        new DebeziumJsonDeserializationSchema(
+                                catalogTable, ignoreParseErrors, 
includeSchema);
                 break;
             default:
                 throw new SeaTunnelJsonFormatException(
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReader.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReader.java
index 309b328ee1..5b3d361c31 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReader.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReader.java
@@ -57,7 +57,9 @@ public class AmazonSqsSourceReader extends 
AbstractSingleSplitReader<SeaTunnelRo
             SeaTunnelRowType seaTunnelRowType) {
         this.context = context;
         this.amazonSqsSourceConfig = amazonSqsSourceConfig;
-        this.seaTunnelRowDeserializer = new 
AmazonSqsDeserializer(deserializationSchema);
+        this.seaTunnelRowDeserializer =
+                new AmazonSqsDeserializer(
+                        deserializationSchema, 
amazonSqsSourceConfig.isIgnoreParseErrors());
     }
 
     @Override
@@ -107,7 +109,9 @@ public class AmazonSqsSourceReader extends 
AbstractSingleSplitReader<SeaTunnelRo
         for (Message message : messages) {
             String messageBody = message.body();
             SeaTunnelRow seaTunnelRow = 
this.seaTunnelRowDeserializer.deserializeRow(messageBody);
-            output.collect(seaTunnelRow);
+            if (seaTunnelRow != null) {
+                output.collect(seaTunnelRow);
+            }
 
             // Delete the processed message
             if (amazonSqsSourceConfig.isDeleteMessage()) {
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/AmazonSqsSourceFactoryTest.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/AmazonSqsSourceFactoryTest.java
index e7fe20da9c..e25c3a7a57 100644
--- 
a/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/AmazonSqsSourceFactoryTest.java
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/AmazonSqsSourceFactoryTest.java
@@ -18,6 +18,7 @@
 package org.apache.seatunnel.connectors.seatunnel.amazonsqs;
 
 import org.apache.seatunnel.api.configuration.util.OptionRule;
+import 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceOptions;
 import 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.sink.AmazonSqsSinkFactory;
 import 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.source.AmazonSqsSourceFactory;
 
@@ -32,6 +33,11 @@ public class AmazonSqsSourceFactoryTest {
         AmazonSqsSourceFactory amazonSqsSourceFactory = new 
AmazonSqsSourceFactory();
         OptionRule sourceOptionRule = amazonSqsSourceFactory.optionRule();
         Assertions.assertNotNull(sourceOptionRule);
+        Assertions.assertTrue(
+                sourceOptionRule
+                        .getOptionalOptions()
+                        .contains(AmazonSqsSourceOptions.IGNORE_PARSE_ERRORS));
+        
Assertions.assertFalse(AmazonSqsSourceOptions.IGNORE_PARSE_ERRORS.defaultValue());
 
         AmazonSqsSinkFactory amazonSqsSinkFactory = new AmazonSqsSinkFactory();
         OptionRule sinkOptionRule = amazonSqsSinkFactory.optionRule();
diff --git 
a/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReaderTest.java
 
b/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReaderTest.java
new file mode 100644
index 0000000000..ed7cb3d1ef
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-amazonsqs/src/test/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSourceReaderTest.java
@@ -0,0 +1,457 @@
+/*
+ * 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.connectors.seatunnel.amazonsqs.source;
+
+import org.apache.seatunnel.api.common.metrics.MetricsContext;
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.event.EventListener;
+import org.apache.seatunnel.api.serialization.DeserializationSchema;
+import org.apache.seatunnel.api.source.Boundedness;
+import org.apache.seatunnel.api.source.Collector;
+import org.apache.seatunnel.api.source.SourceEvent;
+import org.apache.seatunnel.api.source.SourceReader;
+import org.apache.seatunnel.api.table.connector.TableSource;
+import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
+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.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceConfig;
+import 
org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorException;
+import 
org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import software.amazon.awssdk.services.sqs.SqsClient;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest;
+import software.amazon.awssdk.services.sqs.model.DeleteMessageResponse;
+import software.amazon.awssdk.services.sqs.model.Message;
+import software.amazon.awssdk.services.sqs.model.ReceiveMessageResponse;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+class AmazonSqsSourceReaderTest {
+
+    private static final SeaTunnelRowType ROW_TYPE =
+            new SeaTunnelRowType(
+                    new String[] {"value"}, new SeaTunnelDataType[] 
{BasicType.STRING_TYPE});
+
+    @Test
+    void shouldNotDeleteMessageWhenDeserializationThrows() {
+        RecordingSqsClient sqsClient = new 
RecordingSqsClient(message("invalid", "receipt-1"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, true, new 
FailingDeserializationSchema(), sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        AmazonSqsConnectorException exception =
+                Assertions.assertThrows(
+                        AmazonSqsConnectorException.class, () -> 
reader.pollNext(collector));
+
+        Assertions.assertTrue(
+                exception.getMessage().contains("Failed to deserialize Amazon 
SQS message"));
+        Assertions.assertInstanceOf(IOException.class, exception.getCause());
+        Assertions.assertEquals("invalid payload", 
exception.getCause().getMessage());
+        Assertions.assertTrue(collector.records.isEmpty());
+        Assertions.assertTrue(sqsClient.deletedRequests.isEmpty());
+        Assertions.assertEquals(0, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldNotDeleteMessageWhenDeserializerReturnsNull() {
+        RecordingSqsClient sqsClient = new 
RecordingSqsClient(message("invalid", "receipt-1"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, true, new NullDeserializationSchema(), 
sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        AmazonSqsConnectorException exception =
+                Assertions.assertThrows(
+                        AmazonSqsConnectorException.class, () -> 
reader.pollNext(collector));
+
+        Assertions.assertTrue(
+                exception.getMessage().contains("Failed to deserialize Amazon 
SQS message"));
+        Assertions.assertTrue(collector.records.isEmpty());
+        Assertions.assertTrue(sqsClient.deletedRequests.isEmpty());
+        Assertions.assertEquals(0, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldDeleteMessageAfterSuccessfulCollectionWhenEnabled() throws 
Exception {
+        RecordingSqsClient sqsClient = new 
RecordingSqsClient(message("order-1", "receipt-1"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, true, new TestDeserializationSchema(), 
sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        reader.pollNext(collector);
+
+        Assertions.assertEquals(Collections.singletonList("order-1"), 
collector.values());
+        Assertions.assertEquals(
+                Collections.singletonList("receipt-1"), 
sqsClient.deletedReceiptHandles());
+        Assertions.assertEquals(1, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldKeepMessageAfterSuccessfulCollectionWhenDeletionDisabled() 
throws Exception {
+        RecordingSqsClient sqsClient = new 
RecordingSqsClient(message("order-1", "receipt-1"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, false, new TestDeserializationSchema(), 
sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        reader.pollNext(collector);
+
+        Assertions.assertEquals(Collections.singletonList("order-1"), 
collector.values());
+        Assertions.assertTrue(sqsClient.deletedRequests.isEmpty());
+        Assertions.assertEquals(1, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldNotDeleteMessageWhenCollectionFails() {
+        RecordingSqsClient sqsClient = new 
RecordingSqsClient(message("order-1", "receipt-1"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, true, new TestDeserializationSchema(), 
sqsClient);
+        RecordingCollector collector = new RecordingCollector("collector 
rejected row");
+
+        IllegalStateException exception =
+                Assertions.assertThrows(
+                        IllegalStateException.class, () -> 
reader.pollNext(collector));
+
+        Assertions.assertTrue(exception.getMessage().contains("collector 
rejected row"));
+        Assertions.assertTrue(sqsClient.deletedRequests.isEmpty());
+        Assertions.assertEquals(0, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldStopBatchAtFailedMessageWithoutDeletingItOrLaterMessages() {
+        RecordingSqsClient sqsClient =
+                new RecordingSqsClient(
+                        message("order-1", "receipt-1"),
+                        message("invalid", "receipt-2"),
+                        message("order-3", "receipt-3"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, true, new 
SelectiveFailingDeserializationSchema(), sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        AmazonSqsConnectorException exception =
+                Assertions.assertThrows(
+                        AmazonSqsConnectorException.class, () -> 
reader.pollNext(collector));
+
+        Assertions.assertTrue(
+                exception.getMessage().contains("Failed to deserialize Amazon 
SQS message"));
+        Assertions.assertEquals(Collections.singletonList("order-1"), 
collector.values());
+        Assertions.assertEquals(
+                Collections.singletonList("receipt-1"), 
sqsClient.deletedReceiptHandles());
+        Assertions.assertEquals(0, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldSkipFailedMessageAndContinueWhenParseErrorsIgnored() throws 
Exception {
+        RecordingSqsClient sqsClient =
+                new RecordingSqsClient(
+                        message("order-1", "receipt-1"),
+                        message("invalid", "receipt-2"),
+                        message("order-3", "receipt-3"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(
+                        context,
+                        true,
+                        true,
+                        new SelectiveFailingDeserializationSchema(),
+                        sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        reader.pollNext(collector);
+
+        Assertions.assertEquals(Arrays.asList("order-1", "order-3"), 
collector.values());
+        Assertions.assertEquals(
+                Arrays.asList("receipt-1", "receipt-2", "receipt-3"),
+                sqsClient.deletedReceiptHandles());
+        Assertions.assertEquals(1, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldKeepIgnoredNullMessageWhenDeletionDisabled() throws Exception {
+        RecordingSqsClient sqsClient = new 
RecordingSqsClient(message("invalid", "receipt-1"));
+        RecordingReaderContext context = new RecordingReaderContext();
+        AmazonSqsSourceReader reader =
+                createReader(context, false, true, new 
NullDeserializationSchema(), sqsClient);
+        RecordingCollector collector = new RecordingCollector();
+
+        reader.pollNext(collector);
+
+        Assertions.assertTrue(collector.records.isEmpty());
+        Assertions.assertTrue(sqsClient.deletedRequests.isEmpty());
+        Assertions.assertEquals(1, context.noMoreElementSignals);
+    }
+
+    @Test
+    void shouldApplyIgnoreParseErrorsToJsonMessagesCreatedByFactory() throws 
Exception {
+        RecordingSqsClient sqsClient =
+                new RecordingSqsClient(
+                        message("invalid", "receipt-1"),
+                        message("{\"value\":\"order-2\"}", "receipt-2"));
+        RecordingReaderContext context = new RecordingReaderContext();
+
+        Map<String, Object> fields = new HashMap<>();
+        fields.put("value", "string");
+        Map<String, Object> schema = new HashMap<>();
+        schema.put("fields", fields);
+        Map<String, Object> options = new HashMap<>();
+        options.put("url", 
"https://sqs.us-east-1.amazonaws.com/123456789012/orders";);
+        options.put("region", "us-east-1");
+        options.put("schema", schema);
+        options.put("delete_message", true);
+        options.put("ignore_parse_errors", true);
+
+        TableSource<?, ?, ?> tableSource =
+                new AmazonSqsSourceFactory()
+                        .createSource(
+                                new TableSourceFactoryContext(
+                                        ReadonlyConfig.fromMap(options),
+                                        
Thread.currentThread().getContextClassLoader()));
+        AmazonSqsSource source = (AmazonSqsSource) tableSource.createSource();
+        AmazonSqsSourceReader reader =
+                (AmazonSqsSourceReader) source.createReader(new 
SingleSplitReaderContext(context));
+        reader.sqsClient = sqsClient.client;
+        RecordingCollector collector = new RecordingCollector();
+
+        reader.pollNext(collector);
+
+        Assertions.assertEquals(Collections.singletonList("order-2"), 
collector.values());
+        Assertions.assertEquals(
+                Arrays.asList("receipt-1", "receipt-2"), 
sqsClient.deletedReceiptHandles());
+        Assertions.assertEquals(1, context.noMoreElementSignals);
+    }
+
+    private static AmazonSqsSourceReader createReader(
+            RecordingReaderContext context,
+            boolean deleteMessage,
+            DeserializationSchema<SeaTunnelRow> deserializationSchema,
+            RecordingSqsClient sqsClient) {
+        return createReader(context, deleteMessage, false, 
deserializationSchema, sqsClient);
+    }
+
+    private static AmazonSqsSourceReader createReader(
+            RecordingReaderContext context,
+            boolean deleteMessage,
+            boolean ignoreParseErrors,
+            DeserializationSchema<SeaTunnelRow> deserializationSchema,
+            RecordingSqsClient sqsClient) {
+        AmazonSqsSourceConfig config =
+                new AmazonSqsSourceConfig(
+                        
"https://sqs.us-east-1.amazonaws.com/123456789012/orders";,
+                        "us-east-1",
+                        null,
+                        null,
+                        null,
+                        deleteMessage,
+                        ignoreParseErrors,
+                        null);
+        AmazonSqsSourceReader reader =
+                new AmazonSqsSourceReader(
+                        new SingleSplitReaderContext(context),
+                        config,
+                        deserializationSchema,
+                        ROW_TYPE);
+        reader.sqsClient = sqsClient.client;
+        return reader;
+    }
+
+    private static Message message(String body, String receiptHandle) {
+        return 
Message.builder().body(body).receiptHandle(receiptHandle).build();
+    }
+
+    private static class TestDeserializationSchema implements 
DeserializationSchema<SeaTunnelRow> {
+
+        @Override
+        public SeaTunnelRow deserialize(byte[] message) throws IOException {
+            return new SeaTunnelRow(new Object[] {new String(message, 
StandardCharsets.UTF_8)});
+        }
+
+        @Override
+        public SeaTunnelDataType<SeaTunnelRow> getProducedType() {
+            return ROW_TYPE;
+        }
+    }
+
+    private static final class FailingDeserializationSchema extends 
TestDeserializationSchema {
+        @Override
+        public SeaTunnelRow deserialize(byte[] message) throws IOException {
+            throw new IOException("invalid payload");
+        }
+    }
+
+    private static final class NullDeserializationSchema extends 
TestDeserializationSchema {
+        @Override
+        public SeaTunnelRow deserialize(byte[] message) throws IOException {
+            return null;
+        }
+    }
+
+    private static final class SelectiveFailingDeserializationSchema
+            extends TestDeserializationSchema {
+        @Override
+        public SeaTunnelRow deserialize(byte[] message) throws IOException {
+            String value = new String(message, StandardCharsets.UTF_8);
+            if (value.equals("invalid")) {
+                throw new IOException("invalid payload");
+            }
+            return super.deserialize(message);
+        }
+    }
+
+    private static final class RecordingCollector implements 
Collector<SeaTunnelRow> {
+        private final List<SeaTunnelRow> records = new ArrayList<>();
+        private final String failureMessage;
+
+        private RecordingCollector() {
+            this(null);
+        }
+
+        private RecordingCollector(String failureMessage) {
+            this.failureMessage = failureMessage;
+        }
+
+        @Override
+        public void collect(SeaTunnelRow record) {
+            if (failureMessage != null) {
+                throw new IllegalStateException(failureMessage);
+            }
+            records.add(record);
+        }
+
+        @Override
+        public Object getCheckpointLock() {
+            return this;
+        }
+
+        private List<String> values() {
+            List<String> values = new ArrayList<>();
+            for (SeaTunnelRow record : records) {
+                values.add(record.getField(0).toString());
+            }
+            return values;
+        }
+    }
+
+    private static final class RecordingReaderContext implements 
SourceReader.Context {
+        private int noMoreElementSignals;
+
+        @Override
+        public int getIndexOfSubtask() {
+            return 0;
+        }
+
+        @Override
+        public Boundedness getBoundedness() {
+            return Boundedness.BOUNDED;
+        }
+
+        @Override
+        public void signalNoMoreElement() {
+            noMoreElementSignals++;
+        }
+
+        @Override
+        public void sendSplitRequest() {}
+
+        @Override
+        public void sendSourceEventToEnumerator(SourceEvent sourceEvent) {}
+
+        @Override
+        public MetricsContext getMetricsContext() {
+            return null;
+        }
+
+        @Override
+        public EventListener getEventListener() {
+            return null;
+        }
+    }
+
+    private static final class RecordingSqsClient implements InvocationHandler 
{
+        private final SqsClient client;
+        private final List<Message> messages;
+        private final List<DeleteMessageRequest> deletedRequests = new 
ArrayList<>();
+
+        private RecordingSqsClient(Message... messages) {
+            this.messages = 
Collections.unmodifiableList(Arrays.asList(messages));
+            this.client =
+                    (SqsClient)
+                            Proxy.newProxyInstance(
+                                    SqsClient.class.getClassLoader(),
+                                    new Class<?>[] {SqsClient.class},
+                                    this);
+        }
+
+        @Override
+        public Object invoke(Object proxy, Method method, Object[] args) {
+            if (method.getDeclaringClass() == Object.class) {
+                return invokeObjectMethod(proxy, method, args);
+            }
+            if (method.getName().equals("receiveMessage")) {
+                return 
ReceiveMessageResponse.builder().messages(messages).build();
+            }
+            if (method.getName().equals("deleteMessage")) {
+                deletedRequests.add((DeleteMessageRequest) args[0]);
+                return DeleteMessageResponse.builder().build();
+            }
+            if (method.getName().equals("close")) {
+                return null;
+            }
+            throw new UnsupportedOperationException(method.getName());
+        }
+
+        private Object invokeObjectMethod(Object proxy, Method method, 
Object[] args) {
+            if (method.getName().equals("toString")) {
+                return "RecordingSqsClient";
+            }
+            if (method.getName().equals("hashCode")) {
+                return System.identityHashCode(proxy);
+            }
+            if (method.getName().equals("equals")) {
+                return proxy == args[0];
+            }
+            throw new UnsupportedOperationException(method.getName());
+        }
+
+        private List<String> deletedReceiptHandles() {
+            List<String> receiptHandles = new ArrayList<>();
+            for (DeleteMessageRequest request : deletedRequests) {
+                receiptHandles.add(request.receiptHandle());
+            }
+            return receiptHandles;
+        }
+    }
+}

Reply via email to