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-12341-7e715b039f0c33ff002fbf6a56a7a5d6522ab167 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit c6ddef67e4f7e799e012ea2bf73ab9cc869f82f9 Author: Goutam Adwant <[email protected]> AuthorDate: Fri Oct 9 09:25:44 2026 +0000 [Feature][Connector-V2] Add checkpoint-aware ActiveMQ queue source (#12341) Signed-off-by: Goutam Adwant <[email protected]> --- docs/en/connectors/changelog/connector-activemq.md | 1 + docs/en/connectors/sink/Activemq.md | 5 +- docs/en/connectors/source/Activemq.md | 129 +++++ docs/zh/connectors/changelog/connector-activemq.md | 7 + docs/zh/connectors/sink/Activemq.md | 5 +- docs/zh/connectors/source/Activemq.md | 129 +++++ plugin-mapping.properties | 1 + .../activemq/config/ActivemqSourceOptions.java | 51 ++ .../seatunnel/activemq/source/ActivemqSource.java | 98 ++++ .../activemq/source/ActivemqSourceEnumerator.java | 137 +++++ .../activemq/source/ActivemqSourceFactory.java | 131 +++++ .../activemq/source/ActivemqSourceReader.java | 366 ++++++++++++++ .../seatunnel/activemq/ActivemqFactoryTest.java | 97 ++++ .../activemq/source/ActivemqSourceReaderTest.java | 553 +++++++++++++++++++++ .../e2e/connector/activemq/ActivemqIT.java | 284 ++++++++++- .../test/resources/activemq_source_to_console.conf | 39 ++ 16 files changed, 2018 insertions(+), 15 deletions(-) diff --git a/docs/en/connectors/changelog/connector-activemq.md b/docs/en/connectors/changelog/connector-activemq.md index 4a9eb97933..f8d1157bb0 100644 --- a/docs/en/connectors/changelog/connector-activemq.md +++ b/docs/en/connectors/changelog/connector-activemq.md @@ -2,6 +2,7 @@ | Change | Commit | Version | | --- | --- | --- | +|[Feature][Connector-V2] Add checkpoint-aware ActiveMQ queue source|-|Next| |[Improve][connector][activemq] Remove duplicate dependencies (#8753)|https://github.com/apache/seatunnel/commit/da6241aa1c|2.3.10| |[improve] update activemq connector config option (#8580)|https://github.com/apache/seatunnel/commit/629f85b23a|2.3.10| |[Improve][dist]add shade check rule (#8136)|https://github.com/apache/seatunnel/commit/51ef800016|2.3.9| diff --git a/docs/en/connectors/sink/Activemq.md b/docs/en/connectors/sink/Activemq.md index e5590e5f5c..c3aa17156c 100644 --- a/docs/en/connectors/sink/Activemq.md +++ b/docs/en/connectors/sink/Activemq.md @@ -12,8 +12,8 @@ import ChangeLog from '../changelog/connector-activemq.md'; ## Description -Write SeaTunnel rows to an ActiveMQ queue. Each row is serialized as a JSON text message. This is -a sink-only connector; SeaTunnel does not provide an ActiveMQ source connector. +Write SeaTunnel rows to an ActiveMQ queue. Each row is serialized as a JSON text message. +For consuming queues, see the separate [ActiveMQ source](../source/Activemq.md). ## Key features @@ -135,4 +135,3 @@ No. The sink is best-effort with bounded reconnect behavior driven by the underl ## Changelog <ChangeLog /> - diff --git a/docs/en/connectors/source/Activemq.md b/docs/en/connectors/source/Activemq.md new file mode 100644 index 0000000000..47698a7ec1 --- /dev/null +++ b/docs/en/connectors/source/Activemq.md @@ -0,0 +1,129 @@ +import ChangeLog from '../changelog/connector-activemq.md'; + +# ActiveMQ + +<!-- +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. +--> + +> ActiveMQ Classic queue source connector + +## Description + +Reads JMS `TextMessage` payloads from one ActiveMQ Classic queue over OpenWire. Reuses the existing ActiveMQ module and JSON/text deserializers. This is not a generic JMS or AMQP connector. + +## Support Those Engines + +> Flink<br/> +> SeaTunnel Zeta<br/> + +The source requires streaming mode and periodic checkpoints. Spark is not supported by this first slice: its virtual source checkpoints have not been validated as an end-to-end commit boundary for destructive queue acknowledgements. + +## Key Features + +- [ ] [batch](../../introduction/concepts/connector-v2-features.md) +- [x] [stream](../../introduction/concepts/connector-v2-features.md) +- [ ] [exactly-once](../../introduction/concepts/connector-v2-features.md) +- [x] [parallelism](../../introduction/concepts/connector-v2-features.md) +- [ ] [column projection](../../introduction/concepts/connector-v2-features.md) +- [ ] [support user-defined split](../../introduction/concepts/connector-v2-features.md) + +## Options + +| Name | Type | Required | Default | +| --- | --- | --- | --- | +| uri | string | yes | - | +| queue_name | string | yes | - | +| username | string | no | - | +| password | string | no | - | +| format | enum | no | JSON | +| field_delimiter | string | no | , | +| max_in_flight_messages | int | no | 1000 | +| schema | config | yes | - | + +### uri [string] + +One broker endpoint: `tcp://host:port` or `ssl://host:port`. Credentials, URI query options, composite transports (including `failover:`), and URI paths are not supported. Use the separate credential options. For TLS, configure the JVM trust/key stores on every worker and use `ssl://`; never disable certificate validation. Plain TCP should only be used on a trusted network. + +The connector fails the task on a broken connection so the engine restores the reader with a new connection. It does not reconnect in place or reuse acknowledgement handles from an earlier session. + +### queue_name [string] + +One literal queue name. Wildcards, composite queues, destination prefixes and `?consumer.*` destination options are rejected. Topics, durable topic subscriptions and selectors are outside this first slice. Provision the queue and the broker account's permissions before starting the job; broker auto-creation policy is not controlled by the connector. + +### username / password [string] + +Optional credentials, which must be configured together. When omitted, the client connects without explicit credentials. Use a broker account scoped to the intended queue. Do not embed credentials in `uri`. + +### format / field_delimiter + +`JSON` maps an object or an array of objects using `schema`. `TEXT` uses the existing delimited text deserializer, with `field_delimiter` (default `,`). Only JMS `TextMessage` is accepted; `ObjectMessage`, `BytesMessage`, null or empty bodies, and invalid payloads fail the task without acknowledging that message. No Java object payload is deserialized. Deserialization/emission failures omit the original exception details because those details can contain the queue payload; verify the sch [...] + +### max_in_flight_messages [int] + +Positive maximum count of emitted, unacknowledged messages per reader. The source pauses receiving when this limit is reached and resumes after checkpoint completion. The ActiveMQ client queue prefetch is set to the same count; client-side prefetched messages may consume additional memory. This is not a byte limit. Size the limit and producer message sizes for available worker memory. Frequent successful checkpoints are needed for sustained throughput. + +### schema [config] + +Payload schema; see [Schema Feature](../../introduction/concepts/schema-feature.md). Message headers/properties are not exposed as metadata in this slice. + +### Common Options + +See [Source Common Options](../common-options/source-common-options.md). + +## Delivery and Recovery + +- Delivery is **at least once**, not exactly once. Each message is individually acknowledged only after a checkpoint containing its emitted rows completes. A later completed checkpoint can include messages from earlier aborted checkpoints. +- Failure before acknowledgement closes the connection and leaves messages eligible for broker redelivery. Duplicates are possible after recovery, including partially emitted multi-row JSON messages; use an idempotent downstream sink where needed. +- Checkpoint state contains logical consumer slots, not broker message objects or queue offsets. ActiveMQ remains responsible for retaining and redelivering unacknowledged messages. +- Each active parallel reader owns a connection, session and competing queue consumer. Normal startup assigns one slot per reader. Recovery preserves existing logical slots and adds slots when parallelism increases; a reader can own multiple restored slots while using one consumer after scale-down. No global ordering is guaranteed with parallel consumers or redelivery. +- An empty queue is not end-of-input. The source continues waiting until the job is cancelled or fails. +- Retention depends on broker persistence, producer delivery mode, expiration and dead-letter/redelivery policies. A durable broker and persistent messages are required to survive broker restarts. A permanently invalid message can repeatedly fail the job or be routed to a dead-letter queue by broker policy; this connector never silently skips it. + +## Example + +```hocon +env { + parallelism = 2 + job.mode = "STREAMING" + checkpoint.interval = 5000 +} + +source { + ActiveMQ { + uri = "tcp://activemq-host:61616" + queue_name = "events" + format = JSON + max_in_flight_messages = 1000 + schema { + fields { + id = bigint + name = string + } + } + } +} + +sink { + Console {} +} +``` + +For delimited messages such as `42|created`, set `format = TEXT` and `field_delimiter = "|"`, with schema fields in the payload's field order. Existing ActiveMQ **sink** configuration and behavior are unchanged; source options do not apply to the sink. + +## Changelog + +<ChangeLog /> diff --git a/docs/zh/connectors/changelog/connector-activemq.md b/docs/zh/connectors/changelog/connector-activemq.md index e69de29bb2..c9452522e5 100644 --- a/docs/zh/connectors/changelog/connector-activemq.md +++ b/docs/zh/connectors/changelog/connector-activemq.md @@ -0,0 +1,7 @@ +<details><summary> 变更日志 </summary> + +| 变更 | 提交 | 版本 | +| --- | --- | --- | +|[Feature][Connector-V2] Add checkpoint-aware ActiveMQ queue source|-|Next| + +</details> diff --git a/docs/zh/connectors/sink/Activemq.md b/docs/zh/connectors/sink/Activemq.md index c9f045ce25..7cba3be4e3 100644 --- a/docs/zh/connectors/sink/Activemq.md +++ b/docs/zh/connectors/sink/Activemq.md @@ -6,8 +6,8 @@ import ChangeLog from '../changelog/connector-activemq.md'; ## 描述 -用于把 SeaTunnel 数据写入 ActiveMQ 队列。每一行数据都会被序列化成一条 JSON 文本消息。这个连接器只支持 -Sink,SeaTunnel 目前没有提供 ActiveMQ Source 连接器。 +用于把 SeaTunnel 数据写入 ActiveMQ 队列。每一行数据都会被序列化成一条 JSON 文本消息。 +消费队列请参见独立的 [ActiveMQ Source](../source/Activemq.md)。 ## 关键特性 @@ -128,4 +128,3 @@ sink { ## 变更日志 <ChangeLog /> - diff --git a/docs/zh/connectors/source/Activemq.md b/docs/zh/connectors/source/Activemq.md new file mode 100644 index 0000000000..123aba5dd7 --- /dev/null +++ b/docs/zh/connectors/source/Activemq.md @@ -0,0 +1,129 @@ +import ChangeLog from '../changelog/connector-activemq.md'; + +# ActiveMQ + +<!-- +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. +--> + +> ActiveMQ Classic 队列数据源连接器 + +## 描述 + +通过 OpenWire 从一个 ActiveMQ Classic 队列读取 JMS `TextMessage`,复用现有 ActiveMQ 模块以及 JSON 和文本反序列化器。本连接器不是通用 JMS 或 AMQP 实现。 + +## 支持的引擎 + +> Flink<br/> +> SeaTunnel Zeta<br/> + +数据源要求流模式和周期性检查点。本次不支持 Spark:尚未验证其虚拟数据源检查点是否能作为删除队列消息所需的端到端提交边界。 + +## 主要特性 + +- [ ] [批处理](../../introduction/concepts/connector-v2-features.md) +- [x] [流处理](../../introduction/concepts/connector-v2-features.md) +- [ ] [精确一次](../../introduction/concepts/connector-v2-features.md) +- [x] [并行度](../../introduction/concepts/connector-v2-features.md) +- [ ] [列投影](../../introduction/concepts/connector-v2-features.md) +- [ ] [自定义分片](../../introduction/concepts/connector-v2-features.md) + +## 配置项 + +| 名称 | 类型 | 必填 | 默认值 | +| --- | --- | --- | --- | +| uri | string | 是 | - | +| queue_name | string | 是 | - | +| username | string | 否 | - | +| password | string | 否 | - | +| format | enum | 否 | JSON | +| field_delimiter | string | 否 | , | +| max_in_flight_messages | int | 否 | 1000 | +| schema | config | 是 | - | + +### uri [string] + +单个代理地址,格式为 `tcp://host:port` 或 `ssl://host:port`。不支持 URI 中的凭据、查询参数、路径或包括 `failover:` 在内的复合传输。凭据必须使用单独的配置项。TLS 连接应在每个工作节点配置 JVM 信任库和密钥库并使用 `ssl://`,不要禁用证书校验。明文 TCP 仅应在可信网络内使用。 + +连接中断时任务失败,由引擎恢复读取器并创建新连接,不在原会话内重连或复用旧确认句柄。 + +### queue_name [string] + +一个普通队列名称,不支持通配符、复合队列、目标前缀或 `?consumer.*` 参数。本次不支持主题、持久主题订阅和选择器。应提前创建队列并配置账号权限;连接器不控制代理的自动创建策略。 + +### username / password [string] + +可选凭据,必须同时配置。省略时客户端不提供显式凭据。账号权限应限定到目标队列,不要在 URI 中嵌入凭据。 + +### format / field_delimiter + +`JSON` 按 `schema` 映射对象或对象数组;`TEXT` 使用现有分隔文本反序列化器,分隔符 `field_delimiter` 默认为 `,`。仅支持 `TextMessage`;对象消息、字节消息、null 或空字符串消息体以及无效内容使任务失败且不确认该消息,不进行 Java 对象反序列化。反序列化或输出失败时不保留可能包含消息内容的原始异常详情;请通过有权限的代理客户端检查消息与 schema、format 是否匹配。 + +### max_in_flight_messages [int] + +每个读取器允许保留的已输出但尚未确认的消息数,必须大于零。达到上限后暂停接收,完成检查点后恢复。客户端队列预取数设置为相同值,预取消息可能额外占用内存。这是数量限制而不是字节限制,应按工作节点内存和生产者消息大小配置。持续吞吐依赖定期成功完成的检查点。 + +### schema [config] + +消息内容的模式,参见[模式特性](../../introduction/concepts/schema-feature.md)。本次不将消息头或属性暴露为元数据。 + +### 公共配置 + +参见[数据源公共配置](../common-options/source-common-options.md)。 + +## 交付与恢复 + +- 语义为**至少一次**,不是精确一次。只有包含已输出行的检查点完成后,才逐条确认消息。后续成功检查点也可覆盖之前中止的检查点中的消息。 +- 确认前失败会关闭连接,未确认消息可由代理重新投递。恢复可能产生重复行,包括部分输出的多行 JSON 消息;需要时使用幂等下游。 +- 检查点只保存逻辑消费者分片,不保存 JMS 消息对象或队列偏移量;代理负责保留和重新投递未确认消息。 +- 每个活跃并行读取器拥有独立连接、会话和竞争消费者。初次启动每个读取器分配一个分片;恢复保留已有逻辑分片,增加并行度时补充新分片。缩容后一个读取器可以持有多个恢复分片,但只使用一个消费者。并行消费和重投递不保证全局顺序。 +- 空队列不表示输入结束,读取器一直等待到作业取消或失败。 +- 保留和重投递受代理持久化、消息交付模式、过期时间及死信策略影响。要跨代理重启保留消息,需要持久代理及持久消息。永久无效消息可能重复导致任务失败或按代理策略进入死信队列;连接器不会静默跳过。 + +## 示例 + +```hocon +env { + parallelism = 2 + job.mode = "STREAMING" + checkpoint.interval = 5000 +} + +source { + ActiveMQ { + uri = "tcp://activemq-host:61616" + queue_name = "events" + format = JSON + max_in_flight_messages = 1000 + schema { + fields { + id = bigint + name = string + } + } + } +} + +sink { + Console {} +} +``` + +对于 `42|created` 等分隔文本,配置 `format = TEXT`、`field_delimiter = "|"`,并按字段顺序定义模式。现有 ActiveMQ Sink 的配置和行为保持不变;上述 Source 配置不适用于 Sink。 + +## 变更日志 + +<ChangeLog /> diff --git a/plugin-mapping.properties b/plugin-mapping.properties index 2addbea9f5..643774423d 100644 --- a/plugin-mapping.properties +++ b/plugin-mapping.properties @@ -174,6 +174,7 @@ seatunnel.source.BosFile = connector-file-bos seatunnel.sink.BosFile = connector-file-bos seatunnel.source.Milvus = connector-milvus seatunnel.sink.Milvus = connector-milvus +seatunnel.source.ActiveMQ = connector-activemq seatunnel.sink.ActiveMQ = connector-activemq seatunnel.source.MQTT = connector-mqtt seatunnel.sink.MQTT = connector-mqtt diff --git a/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/config/ActivemqSourceOptions.java b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/config/ActivemqSourceOptions.java new file mode 100644 index 0000000000..5806db09ba --- /dev/null +++ b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/config/ActivemqSourceOptions.java @@ -0,0 +1,51 @@ +/* + * 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.activemq.config; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.Options; + +/** Options for consuming one ActiveMQ Classic queue. */ +public final class ActivemqSourceOptions { + public enum MessageFormat { + JSON, + TEXT + } + + public static final Option<MessageFormat> FORMAT = + Options.key("format") + .enumType(MessageFormat.class) + .defaultValue(MessageFormat.JSON) + .withDescription("TextMessage payload format: JSON or delimited TEXT."); + + public static final Option<String> FIELD_DELIMITER = + Options.key("field_delimiter") + .stringType() + .defaultValue(",") + .withDescription("Field delimiter for TEXT payloads."); + + public static final Option<Integer> MAX_IN_FLIGHT_MESSAGES = + Options.key("max_in_flight_messages") + .intType() + .defaultValue(1000) + .withDescription( + "Maximum emitted messages retained per reader until checkpoint completion. " + + "Also bounds the client queue prefetch; this is a count, not a byte limit."); + + private ActivemqSourceOptions() {} +} diff --git a/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSource.java b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSource.java new file mode 100644 index 0000000000..5829ac5e80 --- /dev/null +++ b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSource.java @@ -0,0 +1,98 @@ +/* + * 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.activemq.source; + +import org.apache.seatunnel.api.common.JobContext; +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.serialization.DeserializationSchema; +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.source.SeaTunnelSource; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.source.SourceSplitEnumerator; +import org.apache.seatunnel.api.source.SupportParallelism; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.common.constants.JobMode; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceEnumerator.Split; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceEnumerator.State; + +import java.util.Collections; +import java.util.List; + +/** Competing consumers of one queue; the broker retains messages until checkpoint completion. */ +public class ActivemqSource + implements SeaTunnelSource<SeaTunnelRow, Split, State>, SupportParallelism { + private final ReadonlyConfig config; + private final CatalogTable table; + private final DeserializationSchema<SeaTunnelRow> deserializer; + private JobContext jobContext; + + public ActivemqSource( + ReadonlyConfig config, + CatalogTable table, + DeserializationSchema<SeaTunnelRow> deserializer) { + ActivemqSourceFactory.validate(config); + this.config = config; + this.table = table; + this.deserializer = deserializer; + } + + @Override + public String getPluginName() { + return "ActiveMQ"; + } + + @Override + public Boundedness getBoundedness() { + if (jobContext != null + && (jobContext.getJobMode() != JobMode.STREAMING + || !jobContext.isEnableCheckpoint())) { + throw new IllegalArgumentException( + "ActiveMQ source requires a streaming job with checkpointing enabled"); + } + return Boundedness.UNBOUNDED; + } + + @Override + public void setJobContext(JobContext jobContext) { + this.jobContext = jobContext; + } + + @Override + public List<CatalogTable> getProducedCatalogTables() { + return Collections.singletonList(table); + } + + @Override + public SourceReader<SeaTunnelRow, Split> createReader(SourceReader.Context context) { + getBoundedness(); + return new ActivemqSourceReader(config, deserializer); + } + + @Override + public SourceSplitEnumerator<Split, State> createEnumerator( + SourceSplitEnumerator.Context<Split> context) { + return new ActivemqSourceEnumerator(context); + } + + @Override + public SourceSplitEnumerator<Split, State> restoreEnumerator( + SourceSplitEnumerator.Context<Split> context, State state) { + return new ActivemqSourceEnumerator(context, state); + } +} diff --git a/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceEnumerator.java b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceEnumerator.java new file mode 100644 index 0000000000..dd86ee19b6 --- /dev/null +++ b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceEnumerator.java @@ -0,0 +1,137 @@ +/* + * 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.activemq.source; + +import org.apache.seatunnel.api.source.SourceSplit; +import org.apache.seatunnel.api.source.SourceSplitEnumerator; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Assigns logical consumer slots, not queue offsets; broker acknowledgements determine replay. */ +public class ActivemqSourceEnumerator + implements SourceSplitEnumerator< + ActivemqSourceEnumerator.Split, ActivemqSourceEnumerator.State> { + private final Context<Split> context; + private final Map<Integer, Split> pending = new LinkedHashMap<>(); + private int consumerSlots; + + public ActivemqSourceEnumerator(Context<Split> context) { + this.context = context; + } + + public ActivemqSourceEnumerator(Context<Split> context, State state) { + this.context = context; + this.consumerSlots = state.consumerSlots; + state.pending.forEach(split -> pending.put(split.id, split)); + } + + @Override + public void open() {} + + /** Preserve restored ownership, adding only new consumer slots when parallelism increases. */ + @Override + public synchronized void run() { + for (int i = consumerSlots; i < context.currentParallelism(); i++) { + pending.putIfAbsent(i, new Split(i)); + } + consumerSlots = Math.max(consumerSlots, context.currentParallelism()); + for (Integer reader : context.registeredReaders()) { + assign(reader); + } + } + + private void assign(int reader) { + if (consumerSlots == 0) { + return; + } + List<Split> splits = new ArrayList<>(); + for (Split split : pending.values()) { + if (split.id % context.currentParallelism() == reader) { + splits.add(split); + } + } + if (!splits.isEmpty()) { + context.assignSplit(reader, splits); + splits.forEach(split -> pending.remove(split.id)); + } + context.signalNoMoreSplits(reader); + } + + @Override + public synchronized void addSplitsBack(List<Split> splits, int subtaskId) { + splits.forEach(split -> pending.put(split.id, split)); + for (Integer reader : context.registeredReaders()) { + assign(reader); + } + } + + @Override + public synchronized void registerReader(int subtaskId) { + assign(subtaskId); + } + + @Override + public synchronized void handleSplitRequest(int subtaskId) { + assign(subtaskId); + } + + @Override + public synchronized int currentUnassignedSplitSize() { + return pending.size(); + } + + @Override + public synchronized State snapshotState(long checkpointId) { + return new State(consumerSlots, new ArrayList<>(pending.values())); + } + + @Override + public void notifyCheckpointComplete(long checkpointId) {} + + @Override + public void close() {} + + public static final class Split implements SourceSplit { + private static final long serialVersionUID = 1L; + private final int id; + + public Split(int id) { + this.id = id; + } + + @Override + public String splitId() { + return "consumer-" + id; + } + } + + public static final class State implements Serializable { + private static final long serialVersionUID = 1L; + private final int consumerSlots; + private final List<Split> pending; + + private State(int consumerSlots, List<Split> pending) { + this.consumerSlots = consumerSlots; + this.pending = pending; + } + } +} diff --git a/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceFactory.java b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceFactory.java new file mode 100644 index 0000000000..41b8476238 --- /dev/null +++ b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceFactory.java @@ -0,0 +1,131 @@ +/* + * 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.activemq.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.ConfigValidator; +import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.serialization.DeserializationSchema; +import org.apache.seatunnel.api.source.SeaTunnelSource; +import org.apache.seatunnel.api.source.SourceSplit; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.catalog.CatalogTableUtil; +import org.apache.seatunnel.api.table.connector.TableSource; +import org.apache.seatunnel.api.table.factory.Factory; +import org.apache.seatunnel.api.table.factory.TableSourceFactory; +import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSourceOptions.MessageFormat; +import org.apache.seatunnel.format.json.JsonDeserializationSchema; +import org.apache.seatunnel.format.text.TextDeserializationSchema; + +import com.google.auto.service.AutoService; + +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; + +import static org.apache.seatunnel.api.configuration.util.Conditions.greaterThan; +import static org.apache.seatunnel.api.configuration.util.Conditions.notBlank; +import static org.apache.seatunnel.api.options.ConnectorCommonOptions.SCHEMA; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.PASSWORD; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.QUEUE_NAME; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.URI; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.USERNAME; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSourceOptions.FIELD_DELIMITER; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSourceOptions.FORMAT; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSourceOptions.MAX_IN_FLIGHT_MESSAGES; + +/** Factory for the streaming, checkpoint-aware ActiveMQ Classic queue source. */ +@AutoService(Factory.class) +public class ActivemqSourceFactory implements TableSourceFactory { + + @Override + public String factoryIdentifier() { + return "ActiveMQ"; + } + + @Override + public OptionRule optionRule() { + return OptionRule.builder() + .required(URI, notBlank(URI)) + .required(QUEUE_NAME, notBlank(QUEUE_NAME)) + .required(SCHEMA) + .bundled(USERNAME, PASSWORD) + .optional(FORMAT) + .optional(FIELD_DELIMITER, notBlank(FIELD_DELIMITER)) + .optional(MAX_IN_FLIGHT_MESSAGES, greaterThan(MAX_IN_FLIGHT_MESSAGES, 0)) + .build(); + } + + @Override + public <T, SplitT extends SourceSplit, StateT extends Serializable> + TableSource<T, SplitT, StateT> createSource(TableSourceFactoryContext context) { + ReadonlyConfig config = context.getOptions(); + validate(config); + CatalogTable table = CatalogTableUtil.buildWithConfig(config); + DeserializationSchema<SeaTunnelRow> deserializer = + config.get(FORMAT) == MessageFormat.JSON + ? new JsonDeserializationSchema(table, false, false) + : TextDeserializationSchema.builder() + .seaTunnelRowType(table.getSeaTunnelRowType()) + .delimiter(config.get(FIELD_DELIMITER)) + .build(); + return () -> + (SeaTunnelSource<T, SplitT, StateT>) + new ActivemqSource(config, table, deserializer); + } + + /** Validate before opening connections, including callers that construct a source directly. */ + static void validate(ReadonlyConfig config) { + ConfigValidator.of(config).validate(new ActivemqSourceFactory().optionRule()); + try { + URI endpoint = new URI(config.get(URI)); + if (!("tcp".equals(endpoint.getScheme()) || "ssl".equals(endpoint.getScheme())) + || endpoint.getHost() == null + || endpoint.getPort() <= 0 + || endpoint.getPort() > 65535 + || endpoint.getUserInfo() != null + || endpoint.getQuery() != null + || endpoint.getFragment() != null + || (endpoint.getPath() != null && !endpoint.getPath().isEmpty())) { + throw new IllegalArgumentException( + "ActiveMQ source uri must be tcp://host:port or ssl://host:port; " + + "use username/password options for credentials. " + + "Composite transports and URI options are not supported."); + } + } catch (URISyntaxException e) { + // Do not include the supplied URI: it may contain credentials. + throw new IllegalArgumentException("Invalid ActiveMQ source uri"); + } + String queue = config.get(QUEUE_NAME); + if (queue.contains(",") + || queue.contains("?") + || queue.contains(">") + || queue.contains("*") + || queue.contains("://")) { + throw new IllegalArgumentException( + "ActiveMQ source queue_name must name one literal queue without destination options"); + } + } + + @Override + public Class<? extends SeaTunnelSource> getSourceClass() { + return ActivemqSource.class; + } +} diff --git a/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceReader.java b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceReader.java new file mode 100644 index 0000000000..081b464ca8 --- /dev/null +++ b/seatunnel-connectors-v2/connector-activemq/src/main/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceReader.java @@ -0,0 +1,366 @@ +/* + * 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.activemq.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.serialization.DeserializationSchema; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceEnumerator.Split; +import org.apache.seatunnel.format.json.JsonDeserializationSchema; + +import org.apache.activemq.ActiveMQConnection; +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.transport.InactivityIOException; +import org.apache.activemq.transport.TransportDisposedIOException; + +import javax.jms.Connection; +import javax.jms.ConnectionFactory; +import javax.jms.JMSException; +import javax.jms.Message; +import javax.jms.MessageConsumer; +import javax.jms.Session; +import javax.jms.TextMessage; + +import java.io.IOException; +import java.net.Socket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicReference; + +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.PASSWORD; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.QUEUE_NAME; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.URI; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions.USERNAME; +import static org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSourceOptions.MAX_IN_FLIGHT_MESSAGES; + +/** + * Synchronous queue reader with checkpoint-scoped individual acknowledgements. JMS calls and local + * state are serialized; record emission also holds the engine checkpoint lock. Only logical + * consumer slots are serialized: a new connection receives unacknowledged messages again from the + * broker. + */ +public class ActivemqSourceReader implements SourceReader<SeaTunnelRow, Split> { + private final ReadonlyConfig config; + private final DeserializationSchema<SeaTunnelRow> deserializer; + private final ConnectionFactory connectionFactory; + private final Map<String, Split> splits = new LinkedHashMap<>(); + private final Deque<PendingMessage> pending = new ArrayDeque<>(); + // Sequence watermarks avoid copying all pending message handles for every checkpoint. + private final NavigableMap<Long, Long> checkpoints = new TreeMap<>(); + private final AtomicReference<Exception> failure = new AtomicReference<>(); + private final int maxInFlight; + private long sequence; + private volatile boolean closed; + private volatile Connection connection; + private Session session; + private MessageConsumer consumer; + + public ActivemqSourceReader( + ReadonlyConfig config, DeserializationSchema<SeaTunnelRow> deserializer) { + this(config, deserializer, createConnectionFactory(config)); + } + + ActivemqSourceReader( + ReadonlyConfig config, + DeserializationSchema<SeaTunnelRow> deserializer, + ConnectionFactory connectionFactory) { + this.config = config; + this.deserializer = deserializer; + this.connectionFactory = connectionFactory; + this.maxInFlight = config.get(MAX_IN_FLIGHT_MESSAGES); + } + + private static ActiveMQConnectionFactory createConnectionFactory(ReadonlyConfig config) { + ActivemqSourceFactory.validate(config); + ActiveMQConnectionFactory factory = + new ActiveMQConnectionFactory(config.get(URI) + "?connectionTimeout=10000"); + factory.getPrefetchPolicy().setQueuePrefetch(config.get(MAX_IN_FLIGHT_MESSAGES)); + factory.setOptimizeAcknowledge(false); + // The checkpoint is already durable. One-way ACKs avoid an untimed broker request per + // message; a lost ACK can only cause redelivery, not loss of checkpointed data. + factory.setSendAcksAsync(true); + factory.setWatchTopicAdvisories(false); + factory.setTrustAllPackages(false); + factory.setTrustedPackages(Collections.emptyList()); + factory.setCloseTimeout(5000); + factory.setConnectResponseTimeout(10000); + return factory; + } + + /** Connections are opened lazily once this reader receives a consumer slot. */ + @Override + public void open() {} + + private void openConsumer() throws JMSException { + if (consumer != null) { + return; + } + try { + connection = + config.getOptional(USERNAME).isPresent() + ? connectionFactory.createConnection( + config.get(USERNAME), config.get(PASSWORD)) + : connectionFactory.createConnection(); + // close() may have observed null while createConnection was in progress. + if (closed) { + throw new JMSException("ActiveMQ source was closed during connection creation"); + } + connection.setExceptionListener(error -> failure.compareAndSet(null, error)); + // CLIENT_ACKNOWLEDGE is cumulative and could acknowledge post-checkpoint records. + session = connection.createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE); + consumer = session.createConsumer(session.createQueue(config.get(QUEUE_NAME))); + connection.start(); + } catch (JMSException e) { + failure.compareAndSet(null, e); + try { + close(); + } catch (IOException closeError) { + e.addSuppressed(closeError); + } + throw e; + } + } + + /** Emit only from pollNext, never from an asynchronous JMS listener. */ + @Override + public void pollNext(Collector<SeaTunnelRow> output) throws Exception { + synchronized (this) { + checkFailure(); + if (closed) { + return; + } + if (splits.isEmpty() || pending.size() >= maxInFlight) { + wait(100); + return; + } + } + + for (int i = 0; i < 32; i++) { + Message message; + synchronized (this) { + checkFailure(); + if (closed || pending.size() >= maxInFlight) { + return; + } + openConsumer(); + message = i == 0 ? consumer.receive(200) : consumer.receiveNoWait(); + } + if (message == null) { + checkFailure(); + return; + } + synchronized (output.getCheckpointLock()) { + synchronized (this) { + checkFailure(); + if (closed) { + return; + } + try { + if (!(message instanceof TextMessage)) { + throw new IOException( + "ActiveMQ source accepts only JMS TextMessage payloads"); + } + String text = ((TextMessage) message).getText(); + if (text == null) { + throw new IOException( + "ActiveMQ source received a null TextMessage payload"); + } + // The shared text deserializer emits no row for an empty payload. Never + // acknowledge a queue message that would be silently dropped. + if (text.isEmpty()) { + throw new IOException( + "ActiveMQ source received an empty TextMessage payload"); + } + try { + byte[] payload = text.getBytes(StandardCharsets.UTF_8); + if (deserializer instanceof JsonDeserializationSchema) { + ((JsonDeserializationSchema) deserializer).collect(payload, output); + } else { + deserializer.deserialize(payload, output); + } + } catch (Exception error) { + // Shared format errors may embed the full queue payload. Neither retain + // their causes nor expose their messages through engine task logs. + throw new IOException( + "ActiveMQ message deserialization or emission failed (" + + error.getClass().getSimpleName() + + "); check the configured schema and format"); + } + pending.addLast(new PendingMessage(++sequence, message)); + } catch (Exception e) { + failure.compareAndSet(null, e); + // No acknowledgement: closing the failed reader makes the message eligible + // for redelivery, including partial multi-row deserialization/emission. + throw e; + } + } + } + } + } + + @Override + public synchronized void addSplits(List<Split> assigned) { + assigned.forEach(split -> splits.putIfAbsent(split.splitId(), split)); + notifyAll(); + } + + @Override + public void handleNoMoreSplits() { + // An empty queue is not end-of-input for this unbounded source. + } + + /** Snapshot emitted records only; received but not emitted records remain broker-owned. */ + @Override + public synchronized List<Split> snapshotState(long checkpointId) throws Exception { + checkFailure(); + checkpoints.put(checkpointId, sequence); + return new ArrayList<>(splits.values()); + } + + /** Acknowledge only records included in this completed checkpoint, including earlier aborts. */ + @Override + public synchronized void notifyCheckpointComplete(long checkpointId) throws Exception { + if (closed) { + return; + } + checkFailure(); + Long watermark = checkpoints.get(checkpointId); + if (watermark == null) { + return; + } + try { + while (!pending.isEmpty() && pending.peekFirst().sequence <= watermark) { + pending.peekFirst().message.acknowledge(); + pending.removeFirst(); + } + } catch (JMSException e) { + failure.compareAndSet(null, e); + throw new IOException( + "ActiveMQ acknowledgement failed for checkpoint " + checkpointId, e); + } + checkpoints.headMap(checkpointId, true).clear(); + notifyAll(); + } + + @Override + public synchronized void notifyCheckpointAborted(long checkpointId) { + checkpoints.remove(checkpointId); + } + + private void checkFailure() throws IOException { + Exception error = failure.get(); + if (error != null) { + throw new IOException( + "ActiveMQ source reader failed; unacknowledged messages will be redelivered", + error); + } + } + + /** Close every JMS resource without acknowledging buffered or emitted records. */ + @Override + public void close() throws IOException { + closed = true; + // Cancellation must not wait for the JMS lock: the broker may be withholding a response + // while its transport remains alive. Closing the TCP/SSL socket unblocks pending I/O. + Connection activeConnection = connection; + IOException socketError = null; + boolean interruptedTransport = false; + if (activeConnection instanceof ActiveMQConnection) { + Socket socket = + ((ActiveMQConnection) activeConnection).getTransport().narrow(Socket.class); + if (socket != null) { + try { + socket.close(); + interruptedTransport = true; + } catch (IOException e) { + socketError = e; + } + } + } + synchronized (this) { + try { + closeResources(interruptedTransport); + } catch (IOException e) { + if (socketError != null) { + e.addSuppressed(socketError); + } + throw e; + } + } + if (socketError != null) { + throw socketError; + } + } + + private void closeResources(boolean interruptedTransport) throws IOException { + try { + if (connection != null) { + // Connection.close cascades to sessions/consumers and disposes local state even + // if its final broker removal command fails after cancellation closed the socket. + connection.close(); + } + } catch (JMSException e) { + if (!interruptedTransport + || !(connection instanceof ActiveMQConnection) + || !((ActiveMQConnection) connection).isClosed() + || !isTransportCloseError(e)) { + throw new IOException("Could not close ActiveMQ connection", e); + } + } finally { + consumer = null; + session = null; + connection = null; + pending.clear(); + checkpoints.clear(); + notifyAll(); + } + } + + private static boolean isTransportCloseError(JMSException error) { + for (Throwable cause = error.getCause(); cause != null; cause = cause.getCause()) { + if (cause instanceof SocketException + || cause instanceof TransportDisposedIOException + || cause instanceof InactivityIOException) { + return true; + } + } + return false; + } + + private static final class PendingMessage { + private final long sequence; + private final Message message; + + private PendingMessage(long sequence, Message message) { + this.sequence = sequence; + this.message = message; + } + } +} diff --git a/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/ActivemqFactoryTest.java b/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/ActivemqFactoryTest.java index 56defbb500..2141330010 100644 --- a/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/ActivemqFactoryTest.java +++ b/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/ActivemqFactoryTest.java @@ -17,16 +17,23 @@ package org.apache.seatunnel.connectors.seatunnel.activemq; +import org.apache.seatunnel.api.common.JobContext; import org.apache.seatunnel.api.configuration.ReadonlyConfig; import org.apache.seatunnel.api.configuration.util.ConfigValidator; import org.apache.seatunnel.api.configuration.util.OptionRule; import org.apache.seatunnel.api.configuration.util.OptionValidationException; +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.source.SeaTunnelSource; +import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext; +import org.apache.seatunnel.common.constants.JobMode; import org.apache.seatunnel.connectors.seatunnel.activemq.config.ActivemqSinkOptions; import org.apache.seatunnel.connectors.seatunnel.activemq.sink.ActivemqSinkFactory; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceFactory; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -92,6 +99,96 @@ class ActivemqFactoryTest { ConfigValidator.of(readonlyConfig).validate(optionRule); } + @Test + void testSourceOptionsAndFactory() { + ActivemqSourceFactory factory = new ActivemqSourceFactory(); + Map<String, Object> config = sourceConfig(); + ReadonlyConfig options = ReadonlyConfig.fromMap(config); + ConfigValidator.validateUnknownKeys(options, factory.optionRule(), "ActiveMQSource"); + SeaTunnelSource<?, ?, ?> source = createSource(config); + Assertions.assertEquals("ActiveMQ", source.getPluginName()); + Assertions.assertEquals(Boundedness.UNBOUNDED, source.getBoundedness()); + Assertions.assertEquals(1, source.getProducedCatalogTables().size()); + } + + @Test + void testSourceRejectsInvalidOptions() { + for (String key : new String[] {"uri", "queue_name", "field_delimiter"}) { + Map<String, Object> config = sourceConfig(); + config.put(key, " "); + Assertions.assertThrows(OptionValidationException.class, () -> createSource(config)); + } + for (int value : new int[] {0, -1}) { + Map<String, Object> config = sourceConfig(); + config.put("max_in_flight_messages", value); + Assertions.assertThrows(OptionValidationException.class, () -> createSource(config)); + } + for (String key : new String[] {"username", "password"}) { + Map<String, Object> config = sourceConfig(); + config.put(key, "test"); + Assertions.assertThrows(OptionValidationException.class, () -> createSource(config)); + } + } + + @Test + void testSourceRejectsCompositeDestinationsAndTransports() { + for (String uri : + new String[] { + "failover:(tcp://localhost:61616)", + "vm://broker", + "http://localhost:8161", + "tcp://user:secret@localhost:61616", + "tcp://localhost:61616?jms.prefetchPolicy.all=0", + "tcp://localhost", + "tcp://localhost:65536", + "tcp://localhost:61616/path" + }) { + Map<String, Object> config = sourceConfig(); + config.put("uri", uri); + IllegalArgumentException error = + Assertions.assertThrows( + IllegalArgumentException.class, () -> createSource(config)); + Assertions.assertFalse(error.getMessage().contains("secret")); + } + for (String queue : + new String[] {"a,b", "a?consumer.prefetchSize=0", "a.>", "a.*", "topic://a"}) { + Map<String, Object> config = sourceConfig(); + config.put("queue_name", queue); + Assertions.assertThrows(IllegalArgumentException.class, () -> createSource(config)); + } + } + + @Test + void testSourceRequiresStreamingCheckpoints() { + SeaTunnelSource<?, ?, ?> source = createSource(sourceConfig()); + JobContext context = new JobContext(); + context.setJobMode(JobMode.BATCH); + context.setEnableCheckpoint(true); + source.setJobContext(context); + Assertions.assertThrows(IllegalArgumentException.class, source::getBoundedness); + context.setJobMode(JobMode.STREAMING); + context.setEnableCheckpoint(false); + Assertions.assertThrows(IllegalArgumentException.class, source::getBoundedness); + context.setEnableCheckpoint(true); + Assertions.assertEquals(Boundedness.UNBOUNDED, source.getBoundedness()); + } + + private SeaTunnelSource<?, ?, ?> createSource(Map<String, Object> config) { + return new ActivemqSourceFactory() + .createSource( + new TableSourceFactoryContext( + ReadonlyConfig.fromMap(config), getClass().getClassLoader())) + .createSource(); + } + + private Map<String, Object> sourceConfig() { + Map<String, Object> config = requiredConfig(); + config.put( + "schema", + Collections.singletonMap("fields", Collections.singletonMap("value", "string"))); + return config; + } + private Map<String, Object> requiredConfig() { Map<String, Object> config = new HashMap<>(); config.put(ActivemqSinkOptions.URI.key(), "tcp://localhost:61616"); diff --git a/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceReaderTest.java b/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceReaderTest.java new file mode 100644 index 0000000000..ca2c5eee80 --- /dev/null +++ b/seatunnel-connectors-v2/connector-activemq/src/test/java/org/apache/seatunnel/connectors/seatunnel/activemq/source/ActivemqSourceReaderTest.java @@ -0,0 +1,553 @@ +/* + * 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.activemq.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.serialization.DeserializationSchema; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceSplitEnumerator; +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.activemq.source.ActivemqSourceEnumerator.Split; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceEnumerator.State; +import org.apache.seatunnel.format.json.JsonDeserializationSchema; + +import org.apache.activemq.ActiveMQConnection; +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.transport.Transport; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import javax.jms.Connection; +import javax.jms.ConnectionFactory; +import javax.jms.ExceptionListener; +import javax.jms.JMSException; +import javax.jms.Message; +import javax.jms.MessageConsumer; +import javax.jms.ObjectMessage; +import javax.jms.Queue; +import javax.jms.Session; +import javax.jms.TextMessage; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.Socket; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ActivemqSourceReaderTest { + private ConnectionFactory factory; + private Connection connection; + private Session session; + private MessageConsumer consumer; + private Collector<SeaTunnelRow> collector; + private DeserializationSchema<SeaTunnelRow> deserializer; + private ActivemqSourceReader reader; + private java.util.Queue<Message> messages; + + @BeforeEach + void setUp() throws Exception { + factory = mock(ConnectionFactory.class); + connection = mock(Connection.class); + session = mock(Session.class); + consumer = mock(MessageConsumer.class); + collector = mock(Collector.class); + deserializer = mock(DeserializationSchema.class); + messages = new ArrayDeque<>(); + when(factory.createConnection()).thenReturn(connection); + when(connection.createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE)) + .thenReturn(session); + when(session.createQueue("events")).thenReturn(mock(Queue.class)); + when(session.createConsumer(any())).thenReturn(consumer); + when(consumer.receive(anyLong())).thenAnswer(call -> messages.poll()); + when(consumer.receiveNoWait()).thenAnswer(call -> messages.poll()); + when(collector.getCheckpointLock()).thenReturn(new Object()); + reader = new ActivemqSourceReader(config(), deserializer, factory); + reader.open(); + reader.addSplits(Collections.singletonList(new Split(0))); + } + + @AfterEach + void tearDown() throws Exception { + reader.close(); + } + + @Test + void acknowledgesOnlyTheCompletedSnapshot() throws Exception { + TextMessage first = enqueue("first"); + reader.pollNext(collector); + reader.snapshotState(1); + TextMessage later = enqueue("later"); + reader.pollNext(collector); + verify(first, never()).acknowledge(); + reader.notifyCheckpointComplete(1); + verify(first).acknowledge(); + verify(later, never()).acknowledge(); + reader.snapshotState(2); + reader.notifyCheckpointComplete(2); + verify(later).acknowledge(); + verify(connection).createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE); + } + + @Test + void abortedSnapshotRemainsCoveredByLaterCompletion() throws Exception { + TextMessage first = enqueue("first"); + reader.pollNext(collector); + reader.snapshotState(1); + reader.notifyCheckpointAborted(1); + reader.notifyCheckpointComplete(1); + verify(first, never()).acknowledge(); + reader.snapshotState(2); + reader.notifyCheckpointComplete(2); + verify(first).acknowledge(); + } + + @Test + void completionIsIdempotentAndHandlesSubsumedCheckpoints() throws Exception { + TextMessage first = enqueue("first"); + reader.pollNext(collector); + reader.snapshotState(1); + reader.snapshotState(2); + reader.notifyCheckpointComplete(99); + verify(first, never()).acknowledge(); + reader.notifyCheckpointComplete(2); + reader.notifyCheckpointComplete(1); + reader.notifyCheckpointComplete(2); + verify(first, times(1)).acknowledge(); + } + + @Test + void backpressureStopsReceivingUntilCheckpointCompletes() throws Exception { + TextMessage first = enqueue("first"); + enqueue("second"); + TextMessage third = enqueue("third"); + reader.pollNext(collector); + reader.pollNext(collector); + assertEquals(1, messages.size()); + reader.snapshotState(1); + reader.notifyCheckpointComplete(1); + verify(first).acknowledge(); + reader.pollNext(collector); + assertEquals(0, messages.size()); + verify(third, never()).acknowledge(); + } + + @Test + void acknowledgementFailureStopsFurtherProcessing() throws Exception { + TextMessage first = enqueue("first"); + TextMessage second = enqueue("second"); + reader.pollNext(collector); + reader.snapshotState(1); + doThrow(new JMSException("disconnected")).when(second).acknowledge(); + assertThrows(IOException.class, () -> reader.notifyCheckpointComplete(1)); + verify(first).acknowledge(); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + assertThrows(IOException.class, () -> reader.snapshotState(2)); + } + + @Test + void malformedPayloadIsNotAcknowledged() throws Exception { + TextMessage message = enqueue("invalid"); + doThrow(new IOException("invalid JSON")) + .when(deserializer) + .deserialize(any(byte[].class), eq(collector)); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + verify(message, never()).acknowledge(); + assertThrows(IOException.class, () -> reader.snapshotState(1)); + } + + @Test + void failedMultiRowEmissionIsNotAcknowledged() throws Exception { + TextMessage message = enqueue("rows"); + doAnswer( + call -> { + Collector<SeaTunnelRow> output = call.getArgument(1); + output.collect(new SeaTunnelRow(new Object[] {"first"})); + throw new IOException("second row failed"); + }) + .when(deserializer) + .deserialize(any(byte[].class), eq(collector)); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + verify(collector).collect(any(SeaTunnelRow.class)); + verify(message, never()).acknowledge(); + } + + @Test + void rejectsObjectMessagesWithoutDeserializingThem() throws Exception { + ObjectMessage message = mock(ObjectMessage.class); + messages.add(message); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + verify(message, never()).getObject(); + verify(message, never()).acknowledge(); + } + + @Test + void rejectsNullTextPayload() throws Exception { + TextMessage message = enqueue(null); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + verify(message, never()).acknowledge(); + } + + @Test + void rejectsEmptyTextPayloadWithoutAcknowledging() throws Exception { + TextMessage message = enqueue(""); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + verify(deserializer, never()).deserialize(any(byte[].class), eq(collector)); + verify(message, never()).acknowledge(); + assertThrows(IOException.class, () -> reader.snapshotState(1)); + } + + @Test + void closeDoesNotAcknowledgeAndClosesAllResources() throws Exception { + TextMessage message = enqueue("pending"); + reader.pollNext(collector); + reader.snapshotState(1); + reader.close(); + reader.notifyCheckpointComplete(1); + reader.close(); + verify(message, never()).acknowledge(); + // JMS Connection.close owns cascading cleanup without separate broker removal requests. + verify(consumer, never()).close(); + verify(session, never()).close(); + verify(connection, times(1)).close(); + } + + @Test + void connectionCleanupFailureIsReported() throws Exception { + reader.pollNext(collector); + doThrow(new JMSException("connection close failed")).when(connection).close(); + assertThrows(IOException.class, reader::close); + verify(connection).close(); + } + + @Test + void failedStartupClosesPartialResources() throws Exception { + doThrow(new JMSException("start failed")).when(connection).start(); + assertThrows(JMSException.class, () -> reader.pollNext(collector)); + verify(connection).close(); + } + + @Test + void asynchronousConnectionFailureIsPropagated() throws Exception { + reader.pollNext(collector); + ArgumentCaptor<ExceptionListener> listener = + ArgumentCaptor.forClass(ExceptionListener.class); + verify(connection).setExceptionListener(listener.capture()); + listener.getValue().onException(new JMSException("broker stopped")); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + } + + @Test + void duplicateRestoredSlotsDoNotCreateExtraConsumers() throws Exception { + reader.addSplits(Arrays.asList(new Split(0), new Split(1))); + reader.pollNext(collector); + assertEquals(2, reader.snapshotState(1).size()); + verify(session, times(1)).createConsumer(any()); + } + + @Test + void unassignedReaderDoesNotConnect() throws Exception { + reader.close(); + reader = new ActivemqSourceReader(config(), deserializer, factory); + reader.open(); + reader.handleNoMoreSplits(); + reader.pollNext(collector); + assertEquals(0, reader.snapshotState(1).size()); + verify(factory, never()).createConnection(); + } + + @Test + void enumeratorRestoresOnlyPendingSlotsAndReassignsReturnedSlots() throws Exception { + SourceSplitEnumerator.Context<Split> context = mock(SourceSplitEnumerator.Context.class); + when(context.currentParallelism()).thenReturn(2); + when(context.registeredReaders()).thenReturn(Collections.singleton(0)); + ActivemqSourceEnumerator enumerator = new ActivemqSourceEnumerator(context); + enumerator.run(); + assertEquals(1, enumerator.currentUnassignedSplitSize()); + State state = enumerator.snapshotState(1); + SourceSplitEnumerator.Context<Split> restoredContext = + mock(SourceSplitEnumerator.Context.class); + when(restoredContext.currentParallelism()).thenReturn(2); + when(restoredContext.registeredReaders()).thenReturn(new HashSet<>(Arrays.asList(0, 1))); + ActivemqSourceEnumerator restored = new ActivemqSourceEnumerator(restoredContext, state); + restored.run(); + verify(restoredContext, never()).assignSplit(eq(0), any(List.class)); + verify(restoredContext).assignSplit(eq(1), any(List.class)); + assertEquals(0, restored.currentUnassignedSplitSize()); + restored.addSplitsBack(Collections.singletonList(new Split(0)), 0); + verify(restoredContext).assignSplit(eq(0), any(List.class)); + // State snapshots must not alias the live pending collection. + ActivemqSourceEnumerator anotherRestore = + new ActivemqSourceEnumerator(restoredContext, state); + assertEquals(1, anotherRestore.currentUnassignedSplitSize()); + } + + @Test + void failedAssignmentDoesNotLosePendingSlots() { + SourceSplitEnumerator.Context<Split> context = mock(SourceSplitEnumerator.Context.class); + when(context.currentParallelism()).thenReturn(1); + when(context.registeredReaders()).thenReturn(Collections.singleton(0)); + doThrow(new IllegalStateException("reader unavailable")) + .when(context) + .assignSplit(eq(0), any(List.class)); + ActivemqSourceEnumerator enumerator = new ActivemqSourceEnumerator(context); + assertThrows(IllegalStateException.class, enumerator::run); + assertEquals(1, enumerator.currentUnassignedSplitSize()); + } + + @Test + void restoreScaleUpAddsOnlyNewSlots() throws Exception { + SourceSplitEnumerator.Context<Split> context = mock(SourceSplitEnumerator.Context.class); + when(context.currentParallelism()).thenReturn(1); + when(context.registeredReaders()).thenReturn(Collections.singleton(0)); + ActivemqSourceEnumerator enumerator = new ActivemqSourceEnumerator(context); + enumerator.run(); + State saved = enumerator.snapshotState(1); + SourceSplitEnumerator.Context<Split> restoredContext = + mock(SourceSplitEnumerator.Context.class); + when(restoredContext.currentParallelism()).thenReturn(4); + when(restoredContext.registeredReaders()) + .thenReturn(new HashSet<>(Arrays.asList(0, 1, 2, 3))); + ActivemqSourceEnumerator restored = new ActivemqSourceEnumerator(restoredContext, saved); + restored.addSplitsBack(Collections.singletonList(new Split(0)), 0); + restored.run(); + restored.run(); + for (int i = 0; i < 4; i++) { + verify(restoredContext, times(1)).assignSplit(eq(i), any(List.class)); + } + assertEquals(0, restored.currentUnassignedSplitSize()); + } + + @Test + void credentialsArePassedSeparatelyToConnectionFactory() throws Exception { + reader.close(); + Map<String, Object> options = new HashMap<>(); + options.put("queue_name", "events"); + options.put("username", "reader"); + options.put("password", "test-password"); + when(factory.createConnection("reader", "test-password")).thenReturn(connection); + reader = new ActivemqSourceReader(ReadonlyConfig.fromMap(options), deserializer, factory); + reader.addSplits(Collections.singletonList(new Split(0))); + reader.pollNext(collector); + verify(factory).createConnection("reader", "test-password"); + verify(factory, never()).createConnection(); + } + + @Test + void cancellationInterruptsTransportBeforeWaitingForReaderLock() throws Exception { + ActiveMQConnection activeConnection = mock(ActiveMQConnection.class); + Transport transport = mock(Transport.class); + Socket socket = mock(Socket.class); + when(factory.createConnection()).thenReturn(activeConnection); + when(activeConnection.createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE)) + .thenReturn(session); + when(activeConnection.getTransport()).thenReturn(transport); + when(transport.narrow(Socket.class)).thenReturn(socket); + CountDownLatch receiving = new CountDownLatch(1); + CountDownLatch socketClosed = new CountDownLatch(1); + when(consumer.receive(anyLong())) + .thenAnswer( + call -> { + receiving.countDown(); + assertTrue(socketClosed.await(10, TimeUnit.SECONDS)); + return null; + }); + doAnswer( + call -> { + socketClosed.countDown(); + return null; + }) + .when(socket) + .close(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future<?> polling = + executor.submit( + () -> { + reader.pollNext(collector); + return null; + }); + assertTrue(receiving.await(5, TimeUnit.SECONDS)); + Future<?> closing = + executor.submit( + () -> { + reader.close(); + return null; + }); + closing.get(5, TimeUnit.SECONDS); + polling.get(5, TimeUnit.SECONDS); + verify(socket).close(); + verify(activeConnection).close(); + } finally { + socketClosed.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void cancellationDuringConnectionCreationClosesNewConnectionWithoutOpeningSession() + throws Exception { + when(factory.createConnection()) + .thenAnswer( + call -> { + // Reproduce close observing null before the newly created connection is + // published. + reader.close(); + return connection; + }); + assertThrows(JMSException.class, () -> reader.pollNext(collector)); + verify(connection, never()).createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE); + verify(connection).close(); + } + + @Test + void realJsonParserFailureDoesNotExposePayloadOrAcknowledgeMessage() throws Exception { + reader.close(); + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"value"}, new SeaTunnelDataType<?>[] {BasicType.STRING_TYPE}); + reader = + new ActivemqSourceReader( + config(), new JsonDeserializationSchema(false, false, rowType), factory); + reader.addSplits(Collections.singletonList(new Split(0))); + String marker = "SYNTHETIC_SECRET_7f83"; + TextMessage message = enqueue("{\"value\":\"" + marker + "\",BROKEN}"); + IOException initial = assertThrows(IOException.class, () -> reader.pollNext(collector)); + IOException subsequent = assertThrows(IOException.class, () -> reader.snapshotState(1)); + for (IOException error : Arrays.asList(initial, subsequent)) { + StringWriter trace = new StringWriter(); + error.printStackTrace(new PrintWriter(trace)); + assertFalse(trace.toString().contains(marker)); + assertTrue(trace.toString().contains("deserialization or emission failed")); + } + verify(message, never()).acknowledge(); + } + + @Test + void jsonArrayAcknowledgesOnlyAfterEveryRowIsEmittedAndCheckpointCompletes() throws Exception { + reader.close(); + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"value"}, new SeaTunnelDataType<?>[] {BasicType.LONG_TYPE}); + reader = + new ActivemqSourceReader( + config(), new JsonDeserializationSchema(false, false, rowType), factory); + reader.addSplits(Collections.singletonList(new Split(0))); + TextMessage valid = enqueue("[{\"value\":1},{\"value\":2}]"); + reader.pollNext(collector); + verify(collector, times(2)).collect(any(SeaTunnelRow.class)); + verify(valid, never()).acknowledge(); + reader.snapshotState(1); + reader.notifyCheckpointComplete(1); + verify(valid).acknowledge(); + TextMessage invalid = enqueue("[{\"value\":3},{\"value\":\"not-a-number\"}]"); + assertThrows(IOException.class, () -> reader.pollNext(collector)); + verify(collector, times(3)).collect(any(SeaTunnelRow.class)); + verify(invalid, never()).acknowledge(); + assertThrows(IOException.class, () -> reader.snapshotState(2)); + } + + @Test + void interruptedTransportErrorIsIgnoredOnlyAfterConnectionHasClosed() throws Exception { + ActiveMQConnection active = useActiveConnection(); + reader.pollNext(collector); + JMSException error = new JMSException("socket closed"); + error.initCause(new java.net.SocketException("socket closed")); + doThrow(error).when(active).close(); + when(active.isClosed()).thenReturn(true); + reader.close(); + verify(active).close(); + } + + @Test + void unrelatedCleanupErrorAfterSocketInterruptionIsStillReported() throws Exception { + ActiveMQConnection active = useActiveConnection(); + reader.pollNext(collector); + doThrow(new JMSException("scheduler shutdown failed")).when(active).close(); + when(active.isClosed()).thenReturn(true); + assertThrows(IOException.class, reader::close); + } + + @Test + void transportErrorBeforeConnectionCleanupCompletesIsStillReported() throws Exception { + ActiveMQConnection active = useActiveConnection(); + reader.pollNext(collector); + JMSException error = new JMSException("socket closed"); + error.initCause(new java.net.SocketException("socket closed")); + doThrow(error).when(active).close(); + when(active.isClosed()).thenReturn(false); + assertThrows(IOException.class, reader::close); + } + + private ActiveMQConnection useActiveConnection() throws Exception { + ActiveMQConnection active = mock(ActiveMQConnection.class); + Transport transport = mock(Transport.class); + when(active.getTransport()).thenReturn(transport); + when(transport.narrow(Socket.class)).thenReturn(mock(Socket.class)); + when(factory.createConnection()).thenReturn(active); + when(active.createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE)) + .thenReturn(session); + return active; + } + + private TextMessage enqueue(String text) throws JMSException { + TextMessage message = mock(TextMessage.class); + when(message.getText()).thenReturn(text); + messages.add(message); + return message; + } + + private ReadonlyConfig config() { + Map<String, Object> config = new HashMap<>(); + config.put("queue_name", "events"); + config.put("max_in_flight_messages", 2); + return ReadonlyConfig.fromMap(config); + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/activemq/ActivemqIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/activemq/ActivemqIT.java index 56b1faeab8..ced6677b74 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/activemq/ActivemqIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/java/org/apache/seatunnel/e2e/connector/activemq/ActivemqIT.java @@ -17,8 +17,20 @@ package org.apache.seatunnel.e2e.connector.activemq; +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSource; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceEnumerator.Split; +import org.apache.seatunnel.connectors.seatunnel.activemq.source.ActivemqSourceFactory; +import org.apache.seatunnel.e2e.common.TestResource; import org.apache.seatunnel.e2e.common.TestSuiteBase; +import org.apache.seatunnel.e2e.common.container.EngineType; import org.apache.seatunnel.e2e.common.container.TestContainer; +import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer; +import org.apache.seatunnel.e2e.common.util.JobIdGenerator; import org.apache.activemq.ActiveMQConnectionFactory; @@ -32,9 +44,12 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; import org.testcontainers.utility.DockerImageName; +import lombok.extern.slf4j.Slf4j; + import javax.jms.Connection; import javax.jms.ConnectionFactory; import javax.jms.JMSException; +import javax.jms.Message; import javax.jms.MessageConsumer; import javax.jms.MessageProducer; import javax.jms.Queue; @@ -43,14 +58,26 @@ import javax.jms.TextMessage; import java.io.IOException; import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; -public class ActivemqIT extends TestSuiteBase { +@Slf4j +public class ActivemqIT extends TestSuiteBase implements TestResource { private static final String ACTIVEMQ_CONTAINER_HOST = "activemq-host"; public GenericContainer<?> activeMQContainer = - new GenericContainer<>(DockerImageName.parse("rmohr/activemq")) + new GenericContainer<>(DockerImageName.parse("apache/activemq-classic:5.18.7")) .withExposedPorts(61616) .withNetworkAliases(ACTIVEMQ_CONTAINER_HOST) .withNetwork(NETWORK); @@ -61,12 +88,13 @@ public class ActivemqIT extends TestSuiteBase { private MessageConsumer consumer; @BeforeAll - public void setup() throws JMSException, InterruptedException { + @Override + public void startUp() throws JMSException, InterruptedException { activeMQContainer .withNetwork(NETWORK) .waitingFor(new HostPortWaitStrategy().withStartupTimeout(Duration.ofMinutes(2))); activeMQContainer.start(); - String brokerUrl = "tcp://127.0.0.1:" + activeMQContainer.getMappedPort(61616); + String brokerUrl = brokerUrl(); ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(brokerUrl); connection = connectionFactory.createConnection(); connection.start(); @@ -83,11 +111,16 @@ public class ActivemqIT extends TestSuiteBase { } @AfterAll + @Override public void tearDown() throws JMSException { - // Cleaning up resources - if (producer != null) producer.close(); - if (session != null) session.close(); - if (connection != null) connection.close(); + try { + if (connection != null) { + // Closing a connection closes its sessions, consumers and producers as well. + connection.close(); + } + } finally { + activeMQContainer.close(); + } } @Test @@ -108,11 +141,244 @@ public class ActivemqIT extends TestSuiteBase { public void testSinkApacheActivemq(TestContainer container) throws IOException, InterruptedException, JMSException { Container.ExecResult execResult = container.executeJob("/fake_source_to_sink.conf"); - TextMessage textMessage = (TextMessage) consumer.receive(); + TextMessage textMessage = (TextMessage) consumer.receive(10000); + Assertions.assertNotNull(textMessage, "Sink did not publish a message"); Assertions.assertTrue(textMessage.getText().contains("map")); Assertions.assertTrue(textMessage.getText().contains("c_boolean")); Assertions.assertTrue(textMessage.getText().contains("c_tinyint")); Assertions.assertTrue(textMessage.getText().contains("c_timestamp")); Assertions.assertEquals(0, execResult.getExitCode(), execResult.getStderr()); } + + @Test + public void testSourceCheckpointDoesNotAcknowledgeLaterMessages() throws Exception { + String queue = newQueue(); + publish(queue, "{\"value\":\"before\"}"); + RowCollector rows = new RowCollector(); + try (SourceReader<SeaTunnelRow, Split> reader = createReader(queue, "JSON")) { + awaitRows(reader, rows, 1); + reader.snapshotState(1); + publish(queue, "{\"value\":\"after\"}"); + awaitRows(reader, rows, 2); + reader.notifyCheckpointComplete(1); + } + assertEquals("{\"value\":\"after\"}", receive(queue)); + assertNull(receive(queue), "The completed-checkpoint message was not acknowledged"); + } + + @Test + public void testSourceAbortCloseAndRestoreRedelivers() throws Exception { + String queue = newQueue(); + publish(queue, "{\"value\":\"replay\"}"); + List<Split> state; + try (SourceReader<SeaTunnelRow, Split> reader = createReader(queue, "JSON")) { + awaitRows(reader, new RowCollector(), 1); + state = reader.snapshotState(1); + reader.notifyCheckpointAborted(1); + } + try (SourceReader<SeaTunnelRow, Split> restored = createReader(queue, "JSON")) { + restored.addSplits(state); + RowCollector rows = new RowCollector(); + awaitRows(restored, rows, 1); + assertEquals("replay", rows.rows.get(0).getField(0)); + restored.snapshotState(2); + restored.notifyCheckpointComplete(2); + } + assertNull(receive(queue)); + } + + @Test + public void testSourceParallelConsumersAndTextDeserialization() throws Exception { + String queue = newQueue(); + try (SourceReader<SeaTunnelRow, Split> first = createReader(queue, "TEXT", 1); + SourceReader<SeaTunnelRow, Split> second = createReader(queue, "TEXT", 1)) { + RowCollector firstRows = new RowCollector(); + RowCollector secondRows = new RowCollector(); + // Register both consumers before publishing; otherwise the first consumer can + // legitimately prefetch the entire queue before its competitor connects. + first.pollNext(firstRows); + second.pollNext(secondRows); + publish(queue, "first"); + publish(queue, "second"); + awaitRows(first, firstRows, 1); + awaitRows(second, secondRows, 1); + Assertions.assertNotEquals( + firstRows.rows.get(0).getField(0), secondRows.rows.get(0).getField(0)); + first.snapshotState(1); + second.snapshotState(1); + first.notifyCheckpointComplete(1); + second.notifyCheckpointComplete(1); + } + assertNull(receive(queue)); + } + + @Test + public void testSourceMalformedMessageRemainsOnQueue() throws Exception { + String queue = newQueue(); + publish(queue, "not-json"); + try (SourceReader<SeaTunnelRow, Split> reader = createReader(queue, "JSON")) { + assertThrows( + Exception.class, + () -> { + RowCollector rows = new RowCollector(); + awaitRows(reader, rows, 1); + }); + } + assertEquals("not-json", receive(queue)); + } + + @Test + public void testSourceEmptyTextMessageRemainsOnQueue() throws Exception { + String queue = newQueue(); + publish(queue, ""); + try (SourceReader<SeaTunnelRow, Split> reader = createReader(queue, "TEXT")) { + assertThrows(IOException.class, () -> reader.pollNext(new RowCollector())); + } + assertEquals("", receive(queue)); + } + + @TestTemplate + @DisabledOnContainer( + value = {}, + type = {EngineType.FLINK, EngineType.SPARK}, + disabledReason = + "This streaming lifecycle test uses Zeta job status and checkpoint REST APIs") + public void testSourceStreamingJob(TestContainer container) throws Exception { + String jobId = String.valueOf(JobIdGenerator.newJobId()); + CompletableFuture<Container.ExecResult> job = + CompletableFuture.supplyAsync( + () -> { + try { + return container.executeJob( + "/activemq_source_to_console.conf", jobId); + } catch (Exception e) { + throw new java.util.concurrent.CompletionException(e); + } + }); + try { + await().atMost(120, TimeUnit.SECONDS) + .untilAsserted( + () -> { + assertStreaming(job); + assertEquals("RUNNING", container.getJobStatus(jobId)); + }); + String value = "activemq-source-" + UUID.randomUUID(); + publish("source-events", "{\"value\":\"" + value + "\"}"); + await().atMost(60, TimeUnit.SECONDS) + .untilAsserted( + () -> { + assertStreaming(job); + Assertions.assertTrue(container.getServerLogs().contains(value)); + }); + // Count from AFTER observing output so the next completed checkpoint covers the row. + long completed = container.getCompletedCheckpointCount(jobId); + await().atMost(60, TimeUnit.SECONDS) + .untilAsserted( + () -> { + assertStreaming(job); + Assertions.assertTrue( + container.getCompletedCheckpointCount(jobId) > completed); + }); + } finally { + if (!job.isDone()) { + Container.ExecResult cancelled = container.cancelJob(jobId); + assertEquals(0, cancelled.getExitCode(), cancelled.getStderr()); + } + Container.ExecResult result = job.get(120, TimeUnit.SECONDS); + assertEquals(0, result.getExitCode(), result.getStderr()); + } + assertNull(receive("source-events"), "The source did not acknowledge the checkpointed row"); + } + + private void assertStreaming(CompletableFuture<Container.ExecResult> job) throws Exception { + if (job.isDone()) { + Assertions.fail("Streaming job ended before cancellation: " + job.get().getStderr()); + } + } + + private SourceReader<SeaTunnelRow, Split> createReader(String queue, String format) + throws Exception { + return createReader(queue, format, 10); + } + + private SourceReader<SeaTunnelRow, Split> createReader( + String queue, String format, int inFlight) throws Exception { + Map<String, Object> options = new HashMap<>(); + options.put("uri", brokerUrl()); + options.put("queue_name", queue); + options.put("format", format); + options.put("max_in_flight_messages", inFlight); + options.put( + "schema", + Collections.singletonMap("fields", Collections.singletonMap("value", "string"))); + ActivemqSource source = + (ActivemqSource) + new ActivemqSourceFactory() + .<SeaTunnelRow, Split, + org.apache.seatunnel.connectors.seatunnel.activemq.source + .ActivemqSourceEnumerator.State> + createSource( + new TableSourceFactoryContext( + ReadonlyConfig.fromMap(options), + getClass().getClassLoader())) + .createSource(); + SourceReader<SeaTunnelRow, Split> reader = source.createReader(null); + reader.open(); + reader.addSplits(Collections.singletonList(new Split(0))); + return reader; + } + + private void awaitRows(SourceReader<SeaTunnelRow, Split> reader, RowCollector rows, int count) + throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (rows.rows.size() < count && System.nanoTime() < deadline) { + reader.pollNext(rows); + } + assertEquals(count, rows.rows.size()); + } + + private String brokerUrl() { + return "tcp://" + + activeMQContainer.getHost() + + ":" + + activeMQContainer.getMappedPort(61616); + } + + private String newQueue() { + return "source-" + UUID.randomUUID(); + } + + private void publish(String queue, String text) throws JMSException { + MessageProducer sender = session.createProducer(session.createQueue(queue)); + try { + sender.send(session.createTextMessage(text)); + } finally { + sender.close(); + } + } + + private String receive(String queue) throws JMSException { + MessageConsumer receiver = session.createConsumer(session.createQueue(queue)); + try { + Message message = receiver.receive(1000); + return message == null ? null : ((TextMessage) message).getText(); + } finally { + receiver.close(); + } + } + + private static final class RowCollector implements Collector<SeaTunnelRow> { + private final List<SeaTunnelRow> rows = new ArrayList<>(); + private final Object lock = new Object(); + + @Override + public void collect(SeaTunnelRow row) { + rows.add(row); + } + + @Override + public Object getCheckpointLock() { + return lock; + } + } } diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/resources/activemq_source_to_console.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/resources/activemq_source_to_console.conf new file mode 100644 index 0000000000..c3bbacd9eb --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-activemq-e2e/src/test/resources/activemq_source_to_console.conf @@ -0,0 +1,39 @@ +# +# 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. +# + +env { + parallelism = 2 + job.mode = "STREAMING" + checkpoint.interval = 1000 +} + +source { + ActiveMQ { + uri = "tcp://activemq-host:61616" + queue_name = "source-events" + max_in_flight_messages = 10 + schema { + fields { + value = string + } + } + } +} + +sink { + Console {} +}
