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-12084-aa24c7e9e59ab0d8cba785bbb974fa83ebfd7164 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit 83454b590dbf9442faecb28974b815fbb6ae2b82 Author: Goutam Adwant <[email protected]> AuthorDate: Fri Sep 4 21:47:44 2026 +0000 [Fix][Connector-V2][AmazonSqs] Support multi-row CDC deserialization (#12084) --- docs/en/connectors/source/AmazonSqs.md | 2 + docs/zh/connectors/source/AmazonSqs.md | 2 + .../deserialize/AmazonSqsDeserializer.java | 69 +++++- .../deserialize/SeaTunnelRowDeserializer.java | 10 + .../amazonsqs/source/AmazonSqsSource.java | 14 +- .../amazonsqs/source/AmazonSqsSourceFactory.java | 14 +- .../amazonsqs/source/AmazonSqsSourceReader.java | 22 +- .../source/AmazonSqsSourceReaderTest.java | 241 ++++++++++++++++++++- 8 files changed, 359 insertions(+), 15 deletions(-) diff --git a/docs/en/connectors/source/AmazonSqs.md b/docs/en/connectors/source/AmazonSqs.md index 443d8434ef..44efc844b8 100644 --- a/docs/en/connectors/source/AmazonSqs.md +++ b/docs/en/connectors/source/AmazonSqs.md @@ -54,8 +54,10 @@ 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). +- A `canal_json` or `debezium_json` message can emit multiple rows. For example, an update emits its before and after rows. - `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. +- For multi-row messages, deletion happens only after every row has been collected. A collection failure keeps the SQS message 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 81f2f7db59..c518aab808 100644 --- a/docs/zh/connectors/source/AmazonSqs.md +++ b/docs/zh/connectors/source/AmazonSqs.md @@ -53,8 +53,10 @@ 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)。 +- 一条 `canal_json` 或 `debezium_json` 消息可能产生多行。例如,更新事件会产生更新前和更新后的行。 - `ignore_parse_errors = false` 会让本次轮询失败并保留无法解析的消息。设置为 `true` 时,源连接器会跳过该消息并继续处理本批次中的其他消息。 - 当 `ignore_parse_errors` 和 `delete_message` 都为 `true` 时,跳过的消息会从 SQS 中删除。如果需要保留这些消息以便重新投递,请保持 `delete_message = false`。 +- 对于产生多行的消息,只有在所有行都成功收集后才会删除消息。收集失败时,SQS 消息会保留以便重新投递。 - `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/deserialize/AmazonSqsDeserializer.java b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/AmazonSqsDeserializer.java index 21314f20ed..45ba43d70d 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 @@ -18,27 +18,46 @@ package org.apache.seatunnel.connectors.seatunnel.amazonsqs.deserialize; import org.apache.seatunnel.api.serialization.DeserializationSchema; +import org.apache.seatunnel.api.source.Collector; import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.common.exception.CommonErrorCode; +import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException; +import org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.MessageFormat; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorException; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; public class AmazonSqsDeserializer implements SeaTunnelRowDeserializer { private final DeserializationSchema<SeaTunnelRow> deserializationSchema; private final boolean ignoreParseErrors; + private final MessageFormat format; public AmazonSqsDeserializer( DeserializationSchema<SeaTunnelRow> deserializationSchema, boolean ignoreParseErrors) { + this(deserializationSchema, ignoreParseErrors, MessageFormat.JSON); + } + + public AmazonSqsDeserializer( + DeserializationSchema<SeaTunnelRow> deserializationSchema, + boolean ignoreParseErrors, + MessageFormat format) { this.deserializationSchema = deserializationSchema; this.ignoreParseErrors = ignoreParseErrors; + this.format = format; } @Override public SeaTunnelRow deserializeRow(String row) { + byte[] message = row.getBytes(StandardCharsets.UTF_8); try { - SeaTunnelRow seaTunnelRow = deserializationSchema.deserialize(row.getBytes()); + SeaTunnelRow seaTunnelRow = deserializationSchema.deserialize(message); if (seaTunnelRow == null && !ignoreParseErrors) { throw new AmazonSqsConnectorException( AmazonSqsConnectorErrorCode.DESERIALIZE_FAILED, @@ -55,4 +74,52 @@ public class AmazonSqsDeserializer implements SeaTunnelRowDeserializer { e); } } + + @Override + public List<SeaTunnelRow> deserializeRows(String row) { + if (format == MessageFormat.CANAL_JSON || format == MessageFormat.DEBEZIUM_JSON) { + return deserializeMultipleRows(row.getBytes(StandardCharsets.UTF_8)); + } + return SeaTunnelRowDeserializer.super.deserializeRows(row); + } + + private List<SeaTunnelRow> deserializeMultipleRows(byte[] message) { + List<SeaTunnelRow> rows = new ArrayList<>(); + try { + deserializationSchema.deserialize(message, new BufferingCollector(rows)); + return rows; + } catch (IOException e) { + if (ignoreParseErrors) { + return Collections.emptyList(); + } + throw new AmazonSqsConnectorException( + AmazonSqsConnectorErrorCode.DESERIALIZE_FAILED, + "Failed to deserialize Amazon SQS message", + e); + } catch (SeaTunnelRuntimeException e) { + if (ignoreParseErrors + && CommonErrorCode.JSON_OPERATION_FAILED.equals(e.getSeaTunnelErrorCode())) { + return Collections.emptyList(); + } + throw e; + } + } + + private static final class BufferingCollector implements Collector<SeaTunnelRow> { + private final List<SeaTunnelRow> rows; + + private BufferingCollector(List<SeaTunnelRow> rows) { + this.rows = rows; + } + + @Override + public void collect(SeaTunnelRow row) { + rows.add(Objects.requireNonNull(row, "Deserialization schema emitted a null row")); + } + + @Override + public Object getCheckpointLock() { + return this; + } + } } diff --git a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/SeaTunnelRowDeserializer.java b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/SeaTunnelRowDeserializer.java index e837324343..3d271d43e0 100644 --- a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/SeaTunnelRowDeserializer.java +++ b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/deserialize/SeaTunnelRowDeserializer.java @@ -19,7 +19,17 @@ package org.apache.seatunnel.connectors.seatunnel.amazonsqs.deserialize; import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import java.util.Collections; +import java.util.List; + public interface SeaTunnelRowDeserializer { SeaTunnelRow deserializeRow(String row); + + default List<SeaTunnelRow> deserializeRows(String row) { + SeaTunnelRow seaTunnelRow = deserializeRow(row); + return seaTunnelRow == null + ? Collections.emptyList() + : Collections.singletonList(seaTunnelRow); + } } diff --git a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSource.java b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSource.java index ee0d078ab1..98a0e2e506 100644 --- a/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSource.java +++ b/seatunnel-connectors-v2/connector-amazonsqs/src/main/java/org/apache/seatunnel/connectors/seatunnel/amazonsqs/source/AmazonSqsSource.java @@ -23,6 +23,7 @@ import org.apache.seatunnel.api.source.SupportColumnProjection; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.MessageFormat; import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader; import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitSource; import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; @@ -39,14 +40,24 @@ public class AmazonSqsSource extends AbstractSingleSplitSource<SeaTunnelRow> private AmazonSqsSourceConfig amazonSqsSourceConfig; private DeserializationSchema<SeaTunnelRow> deserializationSchema; private CatalogTable catalogTable; + private MessageFormat format; public AmazonSqsSource( AmazonSqsSourceConfig amazonSqsSourceConfig, CatalogTable catalogTable, DeserializationSchema<SeaTunnelRow> deserializationSchema) { + this(amazonSqsSourceConfig, catalogTable, deserializationSchema, MessageFormat.JSON); + } + + AmazonSqsSource( + AmazonSqsSourceConfig amazonSqsSourceConfig, + CatalogTable catalogTable, + DeserializationSchema<SeaTunnelRow> deserializationSchema, + MessageFormat format) { this.amazonSqsSourceConfig = amazonSqsSourceConfig; this.catalogTable = catalogTable; this.deserializationSchema = deserializationSchema; + this.format = format; } @Override @@ -71,6 +82,7 @@ public class AmazonSqsSource extends AbstractSingleSplitSource<SeaTunnelRow> readerContext, amazonSqsSourceConfig, deserializationSchema, - catalogTable.getSeaTunnelRowType()); + catalogTable.getSeaTunnelRowType(), + format); } } 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 3ce5fcaf6b..2441e61247 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 @@ -84,14 +84,16 @@ public class AmazonSqsSourceFactory implements TableSourceFactory { public <T, SplitT extends SourceSplit, StateT extends Serializable> TableSource<T, SplitT, StateT> createSource(TableSourceFactoryContext context) { CatalogTable catalogTable = CatalogTableUtil.buildWithConfig(context.getOptions()); + MessageFormat format = context.getOptions().get(FORMAT); DeserializationSchema<SeaTunnelRow> deserializationSchema = - setDeserialization(context.getOptions().toConfig(), catalogTable); + setDeserialization(context.getOptions().toConfig(), catalogTable, format); return () -> (SeaTunnelSource<T, SplitT, StateT>) new AmazonSqsSource( new AmazonSqsSourceConfig(context.getOptions()), catalogTable, - deserializationSchema); + deserializationSchema, + format); } @Override @@ -100,10 +102,9 @@ public class AmazonSqsSourceFactory implements TableSourceFactory { } private DeserializationSchema<SeaTunnelRow> setDeserialization( - Config config, CatalogTable catalogTable) { + Config config, CatalogTable catalogTable, MessageFormat format) { DeserializationSchema<SeaTunnelRow> deserializationSchema; ReadonlyConfig readonlyConfig = ReadonlyConfig.fromConfig(config); - MessageFormat format = readonlyConfig.get(FORMAT); boolean ignoreParseErrors = readonlyConfig.get(IGNORE_PARSE_ERRORS); switch (format) { case JSON: @@ -124,7 +125,7 @@ public class AmazonSqsSourceFactory implements TableSourceFactory { case CANAL_JSON: deserializationSchema = CanalJsonDeserializationSchema.builder(catalogTable) - .setIgnoreParseErrors(ignoreParseErrors) + .setIgnoreParseErrors(false) .build(); break; case DEBEZIUM_JSON: @@ -133,8 +134,7 @@ public class AmazonSqsSourceFactory implements TableSourceFactory { includeSchema = config.getBoolean(DEBEZIUM_RECORD_INCLUDE_SCHEMA.key()); } deserializationSchema = - new DebeziumJsonDeserializationSchema( - catalogTable, ignoreParseErrors, includeSchema); + new DebeziumJsonDeserializationSchema(catalogTable, false, 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 5b3d361c31..ea76675dbb 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 @@ -22,6 +22,7 @@ import org.apache.seatunnel.api.source.Collector; 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.config.MessageFormat; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.deserialize.AmazonSqsDeserializer; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.deserialize.SeaTunnelRowDeserializer; import org.apache.seatunnel.connectors.seatunnel.common.source.AbstractSingleSplitReader; @@ -55,11 +56,25 @@ public class AmazonSqsSourceReader extends AbstractSingleSplitReader<SeaTunnelRo AmazonSqsSourceConfig amazonSqsSourceConfig, DeserializationSchema<SeaTunnelRow> deserializationSchema, SeaTunnelRowType seaTunnelRowType) { + this( + context, + amazonSqsSourceConfig, + deserializationSchema, + seaTunnelRowType, + MessageFormat.JSON); + } + + AmazonSqsSourceReader( + SingleSplitReaderContext context, + AmazonSqsSourceConfig amazonSqsSourceConfig, + DeserializationSchema<SeaTunnelRow> deserializationSchema, + SeaTunnelRowType seaTunnelRowType, + MessageFormat format) { this.context = context; this.amazonSqsSourceConfig = amazonSqsSourceConfig; this.seaTunnelRowDeserializer = new AmazonSqsDeserializer( - deserializationSchema, amazonSqsSourceConfig.isIgnoreParseErrors()); + deserializationSchema, amazonSqsSourceConfig.isIgnoreParseErrors(), format); } @Override @@ -108,8 +123,9 @@ public class AmazonSqsSourceReader extends AbstractSingleSplitReader<SeaTunnelRo for (Message message : messages) { String messageBody = message.body(); - SeaTunnelRow seaTunnelRow = this.seaTunnelRowDeserializer.deserializeRow(messageBody); - if (seaTunnelRow != null) { + List<SeaTunnelRow> seaTunnelRows = + this.seaTunnelRowDeserializer.deserializeRows(messageBody); + for (SeaTunnelRow seaTunnelRow : seaTunnelRows) { output.collect(seaTunnelRow); } 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 index ed7cb3d1ef..a61ceaf60b 100644 --- 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 @@ -28,10 +28,14 @@ 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.RowKind; 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.common.exception.SeaTunnelRuntimeException; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.AmazonSqsSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.amazonsqs.config.MessageFormat; +import org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.amazonsqs.exception.AmazonSqsConnectorException; import org.apache.seatunnel.connectors.seatunnel.common.source.SingleSplitReaderContext; @@ -58,6 +62,20 @@ import java.util.Map; class AmazonSqsSourceReaderTest { + private static final String CANAL_UPDATE_MESSAGE = + "{\"data\":[{\"value\":\"after\"}],\"old\":[{\"value\":\"before\"}]," + + "\"database\":\"inventory\",\"table\":\"orders\",\"type\":\"UPDATE\"}"; + private static final String CANAL_QUERY_MESSAGE = + "{\"data\":null,\"database\":\"inventory\",\"table\":\"orders\"," + + "\"type\":\"QUERY\"}"; + private static final String CANAL_PARTIALLY_INVALID_MESSAGE = + "{\"data\":[{\"value\":\"1\"},{\"value\":\"invalid\"}]," + + "\"database\":\"inventory\",\"table\":\"orders\",\"type\":\"INSERT\"}"; + private static final String DEBEZIUM_UPDATE_MESSAGE = + "{\"schema\":{},\"payload\":{\"before\":{\"value\":\"before\"}," + + "\"after\":{\"value\":\"after\"},\"op\":\"u\"," + + "\"ts_ms\":1598944202218}}"; + private static final SeaTunnelRowType ROW_TYPE = new SeaTunnelRowType( new String[] {"value"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE}); @@ -254,6 +272,178 @@ class AmazonSqsSourceReaderTest { Assertions.assertEquals(1, context.noMoreElementSignals); } + @Test + void shouldDeserializeCanalUpdateAsTwoRows() throws Exception { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(CANAL_UPDATE_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsSourceReader reader = + createReaderFromFactory(context, "canal_json", true, false, sqsClient); + RecordingCollector collector = new RecordingCollector(); + + reader.pollNext(collector); + + Assertions.assertEquals(Arrays.asList("before", "after"), collector.values()); + Assertions.assertEquals( + Arrays.asList(RowKind.UPDATE_BEFORE, RowKind.UPDATE_AFTER), collector.rowKinds()); + Assertions.assertEquals( + Collections.singletonList("receipt-1"), sqsClient.deletedReceiptHandles()); + } + + @Test + void shouldDeserializeDebeziumUpdateAsTwoRows() throws Exception { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(DEBEZIUM_UPDATE_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsSourceReader reader = + createReaderFromFactory(context, "debezium_json", true, false, sqsClient); + RecordingCollector collector = new RecordingCollector(); + + reader.pollNext(collector); + + Assertions.assertEquals(Arrays.asList("before", "after"), collector.values()); + Assertions.assertEquals( + Arrays.asList(RowKind.UPDATE_BEFORE, RowKind.UPDATE_AFTER), collector.rowKinds()); + Assertions.assertEquals( + Collections.singletonList("receipt-1"), sqsClient.deletedReceiptHandles()); + } + + @Test + void shouldDeleteCanalMessageThatProducesNoRows() throws Exception { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(CANAL_QUERY_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsSourceReader reader = + createReaderFromFactory(context, "canal_json", true, false, sqsClient); + RecordingCollector collector = new RecordingCollector(); + + reader.pollNext(collector); + + Assertions.assertTrue(collector.records.isEmpty()); + Assertions.assertEquals( + Collections.singletonList("receipt-1"), sqsClient.deletedReceiptHandles()); + } + + @Test + void shouldNotDeleteCanalMessageWhenSecondCollectionFails() throws Exception { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(CANAL_UPDATE_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsSourceReader reader = + createReaderFromFactory(context, "canal_json", true, false, sqsClient); + RecordingCollector collector = new RecordingCollector("collector rejected row", 1); + + IllegalStateException exception = + Assertions.assertThrows( + IllegalStateException.class, () -> reader.pollNext(collector)); + + Assertions.assertEquals("collector rejected row", exception.getMessage()); + Assertions.assertEquals(Collections.singletonList("before"), collector.values()); + Assertions.assertTrue(sqsClient.deletedRequests.isEmpty()); + } + + @Test + void shouldDiscardBufferedRowsWhenLaterCanalRowCannotBeParsed() throws Exception { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(CANAL_PARTIALLY_INVALID_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsSourceReader reader = + createReaderFromFactory(context, "canal_json", true, true, "int", sqsClient); + RecordingCollector collector = new RecordingCollector(); + + reader.pollNext(collector); + + Assertions.assertTrue(collector.records.isEmpty()); + Assertions.assertEquals( + Collections.singletonList("receipt-1"), sqsClient.deletedReceiptHandles()); + } + + @Test + void shouldKeepPartiallyInvalidCanalMessageWhenParseErrorsAreNotIgnored() throws Exception { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(CANAL_PARTIALLY_INVALID_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsSourceReader reader = + createReaderFromFactory(context, "canal_json", true, false, "int", sqsClient); + RecordingCollector collector = new RecordingCollector(); + + Assertions.assertThrows(SeaTunnelRuntimeException.class, () -> reader.pollNext(collector)); + + Assertions.assertTrue(collector.records.isEmpty()); + Assertions.assertTrue(sqsClient.deletedRequests.isEmpty()); + } + + @Test + void shouldNotSwallowUnrelatedRuntimeFailureForMultiRowFormat() { + RecordingSqsClient sqsClient = + new RecordingSqsClient(message(CANAL_UPDATE_MESSAGE, "receipt-1")); + RecordingReaderContext context = new RecordingReaderContext(); + AmazonSqsConnectorException failure = + new AmazonSqsConnectorException( + AmazonSqsConnectorErrorCode.DESERIALIZE_FAILED, "unexpected failure"); + AmazonSqsSourceReader reader = + createReader( + context, + true, + true, + new RuntimeFailingDeserializationSchema(failure), + sqsClient, + MessageFormat.CANAL_JSON); + RecordingCollector collector = new RecordingCollector(); + + AmazonSqsConnectorException actual = + Assertions.assertThrows( + AmazonSqsConnectorException.class, () -> reader.pollNext(collector)); + + Assertions.assertSame(failure, actual); + Assertions.assertTrue(collector.records.isEmpty()); + Assertions.assertTrue(sqsClient.deletedRequests.isEmpty()); + } + + private static AmazonSqsSourceReader createReaderFromFactory( + RecordingReaderContext context, + String format, + boolean deleteMessage, + boolean ignoreParseErrors, + RecordingSqsClient sqsClient) + throws Exception { + return createReaderFromFactory( + context, format, deleteMessage, ignoreParseErrors, "string", sqsClient); + } + + private static AmazonSqsSourceReader createReaderFromFactory( + RecordingReaderContext context, + String format, + boolean deleteMessage, + boolean ignoreParseErrors, + String fieldType, + RecordingSqsClient sqsClient) + throws Exception { + Map<String, Object> fields = new HashMap<>(); + fields.put("value", fieldType); + 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("format", format); + options.put("delete_message", deleteMessage); + options.put("ignore_parse_errors", ignoreParseErrors); + + 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; + return reader; + } + private static AmazonSqsSourceReader createReader( RecordingReaderContext context, boolean deleteMessage, @@ -268,6 +458,22 @@ class AmazonSqsSourceReaderTest { boolean ignoreParseErrors, DeserializationSchema<SeaTunnelRow> deserializationSchema, RecordingSqsClient sqsClient) { + return createReader( + context, + deleteMessage, + ignoreParseErrors, + deserializationSchema, + sqsClient, + MessageFormat.JSON); + } + + private static AmazonSqsSourceReader createReader( + RecordingReaderContext context, + boolean deleteMessage, + boolean ignoreParseErrors, + DeserializationSchema<SeaTunnelRow> deserializationSchema, + RecordingSqsClient sqsClient, + MessageFormat format) { AmazonSqsSourceConfig config = new AmazonSqsSourceConfig( "https://sqs.us-east-1.amazonaws.com/123456789012/orders", @@ -283,7 +489,8 @@ class AmazonSqsSourceReaderTest { new SingleSplitReaderContext(context), config, deserializationSchema, - ROW_TYPE); + ROW_TYPE, + format); reader.sqsClient = sqsClient.client; return reader; } @@ -331,21 +538,41 @@ class AmazonSqsSourceReaderTest { } } + private static final class RuntimeFailingDeserializationSchema + extends TestDeserializationSchema { + private final AmazonSqsConnectorException failure; + + private RuntimeFailingDeserializationSchema(AmazonSqsConnectorException failure) { + this.failure = failure; + } + + @Override + public void deserialize(byte[] message, Collector<SeaTunnelRow> output) { + throw failure; + } + } + private static final class RecordingCollector implements Collector<SeaTunnelRow> { private final List<SeaTunnelRow> records = new ArrayList<>(); private final String failureMessage; + private final int successfulCollectionsBeforeFailure; private RecordingCollector() { - this(null); + this(null, 0); } private RecordingCollector(String failureMessage) { + this(failureMessage, 0); + } + + private RecordingCollector(String failureMessage, int successfulCollectionsBeforeFailure) { this.failureMessage = failureMessage; + this.successfulCollectionsBeforeFailure = successfulCollectionsBeforeFailure; } @Override public void collect(SeaTunnelRow record) { - if (failureMessage != null) { + if (failureMessage != null && records.size() >= successfulCollectionsBeforeFailure) { throw new IllegalStateException(failureMessage); } records.add(record); @@ -363,6 +590,14 @@ class AmazonSqsSourceReaderTest { } return values; } + + private List<RowKind> rowKinds() { + List<RowKind> rowKinds = new ArrayList<>(); + for (SeaTunnelRow record : records) { + rowKinds.add(record.getRowKind()); + } + return rowKinds; + } } private static final class RecordingReaderContext implements SourceReader.Context {
