diff --git a/.github/autolabeler.yml b/.github/autolabeler.yml index f1f644ab6f..e163956f1a 100644 --- a/.github/autolabeler.yml +++ b/.github/autolabeler.yml @@ -32,6 +32,7 @@ dependency-change: "/project/Dependencies.scala" 'p:influxdb': ["/influxdb"] 'p:ironmq': ["/ironmq"] 'p:jms': ["/jms"] +'p:jakarta-jms': ["/jakarta-jms"] 'p:json-streaming': ["/json-streaming"] 'p:kinesis': ["/kinesis"] 'p:kudu': ["/kudu"] diff --git a/.github/workflows/check-build-test.yml b/.github/workflows/check-build-test.yml index 210ba2456d..1a8ee1a197 100644 --- a/.github/workflows/check-build-test.yml +++ b/.github/workflows/check-build-test.yml @@ -105,6 +105,7 @@ jobs: - { connector: huawei-push-kit } - { connector: influxdb, pre_cmd: 'docker-compose up -d influxdb' } - { connector: ironmq, pre_cmd: 'docker-compose up -d ironauth ironmq' } + - { connector: jakarta-jms } - { connector: jms, pre_cmd: 'docker-compose up -d ibmmq' } - { connector: json-streaming } - { connector: kinesis } diff --git a/build.sbt b/build.sbt index 4f0fb94267..82d84f763d 100644 --- a/build.sbt +++ b/build.sbt @@ -31,6 +31,7 @@ lazy val alpakka = project huaweiPushKit, influxdb, ironmq, + jakartaJms, jms, jsonStreaming, kinesis, @@ -275,6 +276,9 @@ lazy val ironmq = alpakkaProject( Test / fork := true ) +lazy val jakartaJms = alpakkaProject("jakarta-jms", "jakarta-jms", Dependencies.JakartaJms, Scala3.settings) + .settings(mimaPreviousArtifacts := Set.empty) // FIXME remove after first release + lazy val jms = alpakkaProject("jms", "jms", Dependencies.Jms, Scala3.settings) lazy val jsonStreaming = alpakkaProject("json-streaming", "json.streaming", Dependencies.JsonStreaming) @@ -427,6 +431,8 @@ lazy val docs = project "javadoc.java.base_url" -> "https://docs.oracle.com/en/java/javase/11/docs/api/java.base/", "javadoc.java.link_style" -> "direct", "javadoc.javax.jms.base_url" -> "https://docs.oracle.com/javaee/7/api/", + "javadoc.jakarta.jms.base_url" -> "https://jakarta.ee/specifications/messaging/3.1/apidocs/jakarta.messaging/", + "javadoc.jakarta.jms.link_style" -> "direct", "javadoc.com.couchbase.base_url" -> s"https://docs.couchbase.com/sdk-api/couchbase-java-client-${Dependencies.CouchbaseVersion}/", "javadoc.io.pravega.base_url" -> s"http://pravega.io/docs/${Dependencies.PravegaVersionForDocs}/javadoc/clients/", "javadoc.org.apache.kudu.base_url" -> s"https://kudu.apache.org/releases/${Dependencies.KuduVersion}/apidocs/", diff --git a/docs/src/main/paradox/index.md b/docs/src/main/paradox/index.md index 56694b58ad..98609bf0e3 100644 --- a/docs/src/main/paradox/index.md +++ b/docs/src/main/paradox/index.md @@ -46,6 +46,7 @@ The [Alpakka project](https://doc.akka.io/docs/alpakka/current/) is an open sour * [IBM DB2 Event Store](external/db2-event-store.md) * [InfluxDB](influxdb.md) * [IronMQ](ironmq.md) +* [Jakarta Messaging](jakarta-jms/index.md) * [JMS](jms/index.md) * [MongoDB](mongodb.md) * [MQTT](mqtt.md) diff --git a/docs/src/main/paradox/jakarta-jms/browse.md b/docs/src/main/paradox/jakarta-jms/browse.md new file mode 100644 index 0000000000..8626ce053a --- /dev/null +++ b/docs/src/main/paradox/jakarta-jms/browse.md @@ -0,0 +1,61 @@ +# Browse + +## Browsing messages + +The browse source streams the messages in a queue **without consuming them**. + +Unlike the other sources, the browse source will complete after browsing all the messages currently on the queue. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #browse-source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #browse-source } + +A JMS `selector` can be used to filter the messages. Otherwise it will browse the entire content of the queue. + + +**Notes:** + +* Messages may be arriving and expiring while the scan is done. +* The JMS API does not require the content of an enumeration to be a static snapshot of queue content. Whether these changes are visible or not depends on the JMS provider. +* A message must not be returned by a QueueBrowser before its delivery time has been reached. + + + +## Configure JMS browse + +To connect to the JMS broker, first define an appropriate @javadoc[jakarta.jms.ConnectionFactory](jakarta.jms.ConnectionFactory). The Alpakka tests and all examples use ActiveMQ Artemis. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #connection-factory } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #connection-factory } + + +The created @javadoc[ConnectionFactory](jakarta.jms.ConnectionFactory) is then used for the creation of the different JMS sources. + + +The `JmsBrowseSettings` factories allow for passing the actor system to read from the default `alpakka.jakarta-jms.browse` section, or you may pass a `Config` instance which is resolved to a section of the same structure. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala) { #browse-settings } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java) { #consumer-settings } + + +The Alpakka Jakarta Messaging browse source is configured via default settings in the [HOCON](https://github.com/lightbend/config#using-hocon-the-json-superset) config file section `alpakka.jakarta-jms.browse` in your `application.conf`, and settings may be tweaked in the code using the `withXyz` methods. On the second tab the section from `reference.conf` shows the structure to use for configuring multiple set-ups. + +Table +: Setting | Description | Default Value | +------------------------|----------------------------------------------------------------------|---------------------| +connectionFactory | Factory to use for creating JMS connections | Must be set in code | +destination | The queue to browse | Must be set in code | +credentials | JMS broker credentials | Empty | +connectionRetrySettings | Retry characteristics if the connection failed to be established or is taking a long time. | See @ref[Connection Retries](producer.md#connection-retries) + +reference.conf +: @@snip [snip](/jakarta-jms/src/main/resources/reference.conf) { #browse } + diff --git a/docs/src/main/paradox/jakarta-jms/consumer.md b/docs/src/main/paradox/jakarta-jms/consumer.md new file mode 100644 index 0000000000..8b0397e501 --- /dev/null +++ b/docs/src/main/paradox/jakarta-jms/consumer.md @@ -0,0 +1,209 @@ +# Consumer + +The Alpakka Jakarta Messaging connector offers consuming JMS messages from topics or queues: + +* Read `jakarta.jms.Message`s from an Akka Streams source +* Allow for client acknowledgement to the JMS broker +* Allow for JMS transactions +* Read raw JVM types from an Akka Streams Source + +The JMS message model supports several types of message bodies in (see @javadoc[jakarta.jms.Message](jakarta.jms.Message)), which may be created directly from the Akka Stream elements, or in wrappers to access more advanced features. + + +## Receiving messages + +@apidoc[jakartajms.*.JmsConsumer$] offers factory methods to consume JMS messages in a number of ways. + +This example shows how to listen to a JMS queue and emit @javadoc[jakarta.jms.Message](jakarta.jms.Message) elements into the stream. + +The materialized value @apidoc[jakartajms.*.JmsConsumerControl] is used to shut down the consumer (it is a @apidoc[KillSwitch]) and offers the possibility to inspect the connectivity state of the consumer. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #jms-source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #jms-source } + + +## Configure JMS consumers + +To connect to the JMS broker, first define an appropriate @javadoc[jakarta.jms.ConnectionFactory](jakarta.jms.ConnectionFactory). The Alpakka tests and all examples use [ActiveMQ Artemis](https://activemq.apache.org/components/artemis/). + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #connection-factory } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #connection-factory } + + +The created @javadoc[ConnectionFactory](jakarta.jms.ConnectionFactory) is then used for the creation of the different JMS sources. + +The @apidoc[jakartajms.JmsConsumerSettings$] factories allow for passing the actor system to read from the default `alpakka.jakarta-jms.consumer` section, or you may pass a `Config` instance which is resolved to a section of the same structure. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala) { #consumer-settings } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java) { #consumer-settings } + +The Alpakka Jakarta Messaging consumer is configured via default settings in the [HOCON](https://github.com/lightbend/config#using-hocon-the-json-superset) config file section `alpakka.jakarta-jms.consumer` in your `application.conf`, and settings may be tweaked in the code using the `withXyz` methods. On the second tab the section from `reference.conf` shows the structure to use for configuring multiple set-ups. + +Table +: Setting | Description | Default Value | +------------------------|----------------------------------------------------------------------|---------------------| +connectionFactory | Factory to use for creating JMS connections | Must be set in code | +destination | Destination (queue or topic) to send JMS messages to | Must be set in code | +credentials | JMS broker credentials | Empty | +connectionRetrySettings | Retry characteristics if the connection failed to be established or is taking a long time. | See @ref[Connection Retries](producer.md#connection-retries) +sessionCount | Number of parallel sessions to use for receiving JMS messages. | defaults to `1` | +bufferSize | Maximum number of messages to prefetch before applying backpressure. | 100 | +ackTimeout | For use with JMS transactions, only: maximum time given to a message to be committed or rolled back. | 1 second | +maxAckInterval | For use with AckSource, only: The max duration before the queued acks are sent to the broker | Empty | +maxPendingAcks | For use with AckSource, only: The amount of acks that get queued before being sent to the broker | 100 | +selector | JMS selector expression (see [below](#using-jms-selectors)) | Empty | +connectionStatusSubscriptionTimeout | 5 seconds | Time to wait for subscriber of connection status events before starting to discard them | + +reference.conf +: @@snip [snip](/jakarta-jms/src/main/resources/reference.conf) { #consumer } + + +### Broker specific destinations + +To reach out to special features of the JMS broker, destinations can be created as `CustomDestination` which takes a factory method for creating destinations. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #custom-destination } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #custom-destination } + + +## Using JMS client acknowledgement + +Client acknowledgement ensures a message is successfully received by the consumer and notifies the JMS broker for every message. Due to the threading details in JMS brokers, this special source is required (see the explanation below). + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsBufferedAckConnectorsSpec.scala) { #source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsBufferedAckConnectorsTest.java) { #source } + +The `sessionCount` parameter controls the number of JMS sessions to run in parallel. + + +**Notes:** + +* Using multiple sessions increases throughput, especially if acknowledging message by message is desired. +* Messages may arrive out of order if `sessionCount` is larger than 1. +* Message-by-message acknowledgement can be achieved by setting `bufferSize` to 0, thus disabling buffering. The outstanding messages before backpressure will be the `sessionCount`. +* If buffering is enabled then it's possible for messages to remain in the buffer and never be acknowledged (or acknowledged after a long time) when no new elements arrive to reach the `maxPendingAcks` threshold. By setting `maxAckInterval` messages will be acknowledged after the defined interval or number of pending acks, whichever comes first. +* The default `AcknowledgeMode` is `ClientAcknowledge` but can be overridden to custom `AcknowledgeMode`s, even implementation-specific ones by setting the `AcknowledgeMode` in the `JmsConsumerSettings` when creating the stream. + +@@@ warning + +Using a regular `JmsConsumer` with `AcknowledgeMode.ClientAcknowledge` and using `message.acknowledge()` from the stream is not compliant with the JMS specification and can cause issues for some message brokers. `message.acknowledge()` in many cases acknowledges the session and not the message itself, contrary to what the API makes you believe. + +Use this `JmsConsumer.ackSource` as shown above instead. + +@@@ + + +## Using JMS transactions + +JMS transactions may be used with this connector. Be aware that transactions are a heavy-weight tool and may not perform very good. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsTxConnectorsSpec.scala) { #source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsTxConnectorsTest.java) { #source } + +The `sessionCount` parameter controls the number of JMS sessions to run in parallel. + +The `ackTimeout` parameter controls the maximum time given to a message to be committed or rolled back. If the message times out it will automatically be rolled back. This is to prevent stream from starvation if the application fails to commit or rollback a message, or if the message errors out and the stream is resumed by a `decider`. + +**Notes:** + +* Higher throughput is achieved by increasing the `sessionCount`. +* Messages will arrive out of order if `sessionCount` is larger than 1. +* Buffering is not supported in transaction mode. The `bufferSize` is ignored. +* The default `AcknowledgeMode` is `SessionTransacted` but can be overridden to custom `AcknowledgeMode`s, even implementation-specific ones by setting the `AcknowledgeMode` in the `JmsConsumerSettings` when creating the stream. + + + +## Using JMS selectors + +Create a @javadoc[jakarta.jms.Message](jakarta.jms.Message) source specifying a [JMS selector expression](https://docs.oracle.com/cd/E19798-01/821-1841/bncer/index.html): +Verify that we are only receiving messages according to the selector: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #source-with-selector } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #source-with-selector } + + +## Raw JVM type sources + +| Stream element type | Alpakka source factory | +|-----------------------------------------------------------|--------------------------| +| `String` | [`JmsConsumer.textSource`](#text-sources) | +| @scala[`Array[Byte]`]@java[`byte[]`] | [`JmsConsumer.bytesSource`](#byte-array-sources) | +| @scala[`Map[String, AnyRef]`]@java[`Map`] | [`JmsConsumer.mapSource`](#map-messages-sources) | +| `Object` (`java.io.Serializable`) | [`JmsConsumer.objectSource`](#object-sources) | + +### Text sources + +The `textSource` emits the received message body as String: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #text-source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #text-source } + + +### Byte array sources + +The `bytesSource` emits the received message body as byte array: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #bytearray-source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #bytearray-source } + + +### Map sources + +The `mapSource` emits the received message body as @scala[Map[String, Object]]@java[Map]: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #map-source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #map-source } + + +### Object sources + +The `objectSource` emits the received message body as deserialized JVM instance. As serialization may be a security concern, JMS clients require special configuration to allow this. The example shows how to configure ActiveMQ Artemis connection factory to support serialization. See [Controlling JMS ObjectMessage deserialization](https://activemq.apache.org/components/artemis/documentation/latest/security.html#controlling-jms-objectmessage-deserialization) for more information on this. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #object-source } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #object-source } + + +## Request / Reply + +The request / reply pattern can be implemented by streaming a @apidoc[jakartajms.*.JmsConsumer$] +to a @apidoc[jakartajms.*.JmsProducer$], +with a stage in between that extracts the `ReplyTo` and `CorrelationID` from the original message and adds them to the response. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #request-reply } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #request-reply } diff --git a/docs/src/main/paradox/jakarta-jms/index.md b/docs/src/main/paradox/jakarta-jms/index.md new file mode 100644 index 0000000000..cc6c0cda32 --- /dev/null +++ b/docs/src/main/paradox/jakarta-jms/index.md @@ -0,0 +1,41 @@ +# Jakarta Messaging (JMS) + +@@@ note { title="Jakarta Messaging (JMS)" } + +The Jakarta Messaging API (formerly Java Message Service or JMS API) is a Java application programming interface (API) for message-oriented middleware. It provides generic messaging models, able to handle the producer–consumer problem, that can be used to facilitate the sending and receiving of messages between software systems. Jakarta Messaging is a part of [Jakarta EE]((https://jakarta.ee)) and was originally defined by a specification developed at Sun Microsystems before being guided by the Java Community Process. + +-- [Wikipedia](https://en.wikipedia.org/wiki/Jakarta_Messaging) + +@@@ + +The Alpakka Jakarta Messaging connector provides Akka Stream sources and sinks to connect to Jakarta Messaging providers. + +@@project-info{ projectId="jakarta-jms" } + +## Artifacts + +The Akka dependencies are available from Akka's library repository. To access them there, you need to configure the URL for this repository. + +@@repository [sbt,Maven,Gradle] { +id="akka-repository" +name="Akka library repository" +url="https://repo.akka.io/maven" +} + +Additionally, add the dependencies as below. + +@@dependency [sbt,Maven,Gradle] { + group=com.lightbend.akka + artifact=akka-stream-alpakka-jakarta-jms_$scala.binary.version$ + version=$project.version$ +} + +@@toc { depth=2 } + +@@@ index + +* [p](producer.md) +* [c](consumer.md) +* [c](browse.md) + +@@@ diff --git a/docs/src/main/paradox/jakarta-jms/producer.md b/docs/src/main/paradox/jakarta-jms/producer.md new file mode 100644 index 0000000000..abe065d980 --- /dev/null +++ b/docs/src/main/paradox/jakarta-jms/producer.md @@ -0,0 +1,278 @@ +# Producer + +The Alpakka Jakarta Messaging connector offers producing messages to topics or queues in three ways + +* JVM types to an Akka Streams Sink +* `JmsMessage` sub-types to a Akka Streams Sink or Flow (using `JmsProducer.sink` or `JmsProducer.flow`) +* `JmsEnvelope` sub-types to a Akka Streams Flow (using `JmsProducer.flexiFlow`) to support pass-throughs + +The JMS message model supports several types of message bodies in (see @javadoc[jakarta.jms.Message](jakarta.jms.Message)), which may be created directly from the Akka Stream elements, or in wrappers to access more advanced features. + +| Stream element type | Alpakka producer | +|-----------------------------------------------------------|--------------------------| +| `String` | [`JmsProducer.textSink`](#text-sinks) | +| @scala[`Array[Byte]`]@java[`byte[]`] | [`JmsProducer.bytesSink`](#byte-array-sinks) | +| @scala[`Map[String, AnyRef]`]@java[`Map`] | [`JmsProducer.mapSink`](#map-messages-sink) | +| `Object` (`java.io.Serializable`) | [`JmsProducer.objectSink`](#object-sinks) | +| `JmsTextMessage` | [`JmsProducer.sink`](#a-jmsmessage-sub-type-sink) or [`JmsProducer.flow`](#sending-messages-as-a-flow) | +| `JmsByteMessage` | [`JmsProducer.sink`](#a-jmsmessage-sub-type-sink) or [`JmsProducer.flow`](#sending-messages-as-a-flow) | +| `JmsByteStringMessage` | [`JmsProducer.sink`](#a-jmsmessage-sub-type-sink) or [`JmsProducer.flow`](#sending-messages-as-a-flow) | +| `JmsMapMessage` | [`JmsProducer.sink`](#a-jmsmessage-sub-type-sink) or [`JmsProducer.flow`](#sending-messages-as-a-flow) | +| `JmsObjectMessage` | [`JmsProducer.sink`](#a-jmsmessage-sub-type-sink) or [`JmsProducer.flow`](#sending-messages-as-a-flow) | +| @scala[`JmsEnvelope[PassThrough]`]@java[`JmsEnvelope`] with instances `JmsPassThrough`, `JmsTextMessagePassThrough`, `JmsByteMessagePassThrough`, `JmsByteStringMessagePassThrough`, `JmsMapMessagePassThrough`, `JmsObjectMessagePassThrough` | [`JmsProducer.flexiFlow`](#passing-context-through-the-producer) | + + + +### Configure JMS producers + +To connect to the JMS broker, first define an appropriate @javadoc[jakarta.jms.ConnectionFactory](jakarta.jms.ConnectionFactory). Here we're using [ActiveMQ Artemis](https://activemq.apache.org/components/artemis/). + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #connection-factory } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #connection-factory } + + +The created @javadoc[ConnectionFactory](jakarta.jms.ConnectionFactory) is then used for the creation of the different JMS sinks or sources (see below). + +### A `JmsMessage` sub-type sink + +Use a case class with the subtype of @apidoc[jakartajms.JmsMessage] to wrap the messages you want to send and optionally set message specific properties or headers. +@apidoc[jakartajms.*.JmsProducer$] contains factory methods to facilitate the creation of sinks according to the message type. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #create-jms-sink } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #create-jms-sink } + + +#### Setting JMS message properties + +For every @apidoc[jakartajms.JmsMessage] you can set JMS message properties. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #create-messages-with-properties } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #create-messages-with-properties } + + +#### Setting JMS message header attributes +For every @apidoc[jakartajms.JmsMessage] you can set also JMS message headers. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #create-messages-with-headers } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #create-messages-with-headers } + + +### Raw JVM type sinks + +#### Text sinks + +Create a sink, that accepts and forwards @apidoc[jakartajms.JmsTextMessage$]s to the JMS provider: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #text-sink } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #text-sink } + +#### Byte array sinks + +Create a sink, that accepts and forwards @apidoc[jakartajms.JmsByteMessage$]s to the JMS provider. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #bytearray-sink } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #bytearray-sink } + + +#### Map message sink + +Create a sink, that accepts and forwards @apidoc[jakartajms.JmsMapMessage$]s to the JMS provider: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #map-sink } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #map-sink } + + +#### Object sinks + +Create and configure ActiveMQ Artemis connection factory to support serialization. +See [Controlling JMS ObjectMessage deserialization](https://activemq.apache.org/components/artemis/documentation/latest/security.html#controlling-jms-objectmessage-deserialization) for more information on this. +Create a sink, that accepts and forwards @apidoc[jakartajms.JmsObjectMessage$]s to the JMS provider: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #object-sink } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #object-sink } + + +### Sending messages as a Flow + +The producer can also act as a flow, in order to publish messages in the middle of stream processing. +For example, you can ensure that a message is persisted to the queue before subsequent processing. + + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #flow-producer } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #flow-producer } + + +### Sending messages with per-message destinations + +It is also possible to define message destinations per message: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #run-directed-flow-producer } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #run-directed-flow-producer } + +When no destination is defined on the message, the destination given in the producer settings is used. + + +### Passing context through the producer + +In some use cases, it is useful to pass through context information when producing (e.g. for acknowledging or committing +messages after sending to Jms). For this, the `JmsProducer.flexiFlow` accepts implementations of `JmsEnvelope`, +which it will pass through: + +* `JmsPassThrough` +* `JmsTextMessagePassThrough` +* `JmsByteMessagePassThrough` +* `JmsByteStringMessagePassThrough` +* `JmsMapMessagePassThrough` +* `JmsObjectMessagePassThrough` + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #run-flexi-flow-producer } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #run-flexi-flow-producer } + +There are two implementations: One envelope type containing a messages to send to Jms, and one +envelope type containing only values to pass through. This allows messages to flow without producing any new messages +to Jms. This is primarily useful when committing offsets back to Kakfa, or when acknowledging Jms messages after sending +the outcome of processing them back to Jms. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #run-flexi-flow-pass-through-producer } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java) { #run-flexi-flow-pass-through-producer } + + +## Producer Settings + +The Alpakka Jakarta Messaging producer is configured via default settings in the [HOCON](https://github.com/lightbend/config#using-hocon-the-json-superset) config file section `alpakka.jakarta-jms.producer` in your `application.conf`, and settings may be tweaked in the code using the `withXyz` methods. + +The `JmsProducerSettings` factories allow for passing the actor system to read from the default `alpakka.jakarta-jms.producer` section, or you may pass a `Config` instance which is resolved to a section of the same structure. + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala) { #producer-settings } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java) { #producer-settings } + +The producer can be configured with the following settings. On the second tab, the section from `reference.conf` shows the structure to use for configuring multiple set-ups. + +Table +: Setting | Defaults | Description | +--------------------------|-------------|---------------------------------------------------------| +connectionFactory | mandatory | Factory to use for creating JMS connections | +destination | mandatory | Destination (queue or topic) to send JMS messages to | +credentials | optional | JMS broker credentials | +connectionRetrySettings | default settings | Retry characteristics if the connection failed to be established or taking a long time. Please see default values under [Connection Retries](#connection-retries) | +sendRetrySettings | default settings | Retry characteristics if message sending failed. Please see default values under [Send Retries](#send-retries) | +sessionCount | defaults to `1` | Number of parallel sessions to use for sending JMS messages. Increasing the number of parallel sessions increases throughput at the cost of message ordering. While the messages may arrive out of order on the JMS broker, the producer flow outputs messages in the order they are received | +timeToLive | optional | Time messages should be kept on the Jms broker. This setting can be overridden on individual messages. If not set, messages will never expire | +connectionStatusSubscriptionTimeout | 5 seconds | Time to wait for subscriber of connection status events before starting to discard them | + +reference.conf +: @@snip [snip](/jakarta-jms/src/main/resources/reference.conf) { #producer } + + +## Connection Retries + +When a connection to a broker cannot be established and errors out, or is timing out being established or started, the connection can be retried. All JMS publishers, consumers, and browsers are configured with connection retry settings. On the second tab the section from `reference.conf` shows the structure to use for configuring multiple set-ups. + +Table +: Setting | Description | Default Value +---------------|-------------------------------------------------------------------------------------|-------------- +connectTimeout | Time allowed to establish and start a connection | 10 s +initialRetry | Wait time before retrying the first time | 100 ms +backoffFactor | Back-off factor for subsequent retries | 2.0 +maxBackoff | Maximum back-off time allowed, after which all retries will happen after this delay | 1 minute +maxRetries | Maximum number of retries allowed (negative value is infinite) | 10 + +reference.conf +: @@snip [snip](/jakarta-jms/src/main/resources/reference.conf) { #connection-retry } + + +The retry time is calculated by: + +*initialRetry \* retryNumberbackoffFactor* + +With the default settings, we'll see retries after 100ms, 400ms, 900ms pauses, until the pauses reach 1 minute and will stay with 1 minute intervals for any subsequent retries. + +Consumers, producers and browsers try to reconnect with the same retry characteristics if a connection fails mid-stream. + +All JMS settings support setting the `connectionRetrySettings` field using `.withConnectionRetrySettings(retrySettings)` on the given settings. The followings show how to create `ConnectionRetrySettings`: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala) { #retry-settings-case-class } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java) { #retry-settings } + +## Send Retries + +When a connection to a broker starts failing, sending JMS messages will also fail. Those failed messages can be retried +at the cost of potentially duplicating the failed messages. Send retries can be configured as follows: + +Table +: Setting | Description | Default Value +---------------|-------------------------------------------------------------------------------------|-------------- +initialRetry | Wait time before retrying the first time | 20 ms +backoffFactor | Back-off factor for subsequent retries | 1.5 +maxBackoff | Maximum back-off time allowed, after which all retries will happen after this delay | 500 ms +maxRetries | Maximum number of retries allowed (negative value is infinite) | 10 + +reference.conf +: @@snip [snip](/jakarta-jms/src/main/resources/reference.conf) { #send-retry } + +The retry time is calculated by: + +*initialRetry \* retryNumberbackoffFactor* + +With the default settings, we'll see retries after 20ms, 57ms, 104ms pauses, until the pauses reach 500 ms and will stay with 500 ms intervals for any subsequent retries. + +JMS producer settings support configuring retries by using `.withSendRetrySettings(retrySettings)`. The followings show how to create `SendRetrySettings`: + +Scala +: @@snip [snip](/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala) { #send-retry-settings } + +Java +: @@snip [snip](/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java) { #send-retry-settings } + +If a send operation finally fails, the stage also fails unless a different supervision strategy is applied. The +producer stage honours stream supervision. + + +### Observing connectivity and state of a JMS producer + +All JMS producer's materialized values are of type `JmsProducerStatus`. This provides a `connectorState` method returning +a `Source` of `JmsConnectorState` updates that publishes connection attempts, disconnections, completions and failures. +The source is completed after the JMS producer completes or fails. + diff --git a/docs/src/main/paradox/jms/consumer.md b/docs/src/main/paradox/jms/consumer.md index d6ef2b743f..ee478f938b 100644 --- a/docs/src/main/paradox/jms/consumer.md +++ b/docs/src/main/paradox/jms/consumer.md @@ -12,11 +12,11 @@ The JMS message model supports several types of message bodies in (see @javadoc[ ## Receiving messages -@apidoc[JmsConsumer$] offers factory methods to consume JMS messages in a number of ways. +@apidoc[.jms.*.JmsConsumer$] offers factory methods to consume JMS messages in a number of ways. This examples shows how to listen to a JMS queue and emit @javadoc[javax.jms.Message](javax.jms.Message) elements into the stream. -The materialized value @apidoc[JmsConsumerControl] is used to shut down the consumer (it is a @apidoc[KillSwitch]) and offers the possibility to inspect the connectivity state of the consumer. +The materialized value @apidoc[.jms.*.JmsConsumerControl] is used to shut down the consumer (it is a @apidoc[KillSwitch]) and offers the possibility to inspect the connectivity state of the consumer. Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #jms-source } @@ -38,7 +38,7 @@ Java The created @javadoc[ConnectionFactory](javax.jms.ConnectionFactory) is then used for the creation of the different JMS sources. -The @apidoc[JmsConsumerSettings$] factories allow for passing the actor system to read from the default `alpakka.jms.consumer` section, or you may pass a `Config` instance which is resolved to a section of the same structure. +The @apidoc[.jms.JmsConsumerSettings$] factories allow for passing the actor system to read from the default `alpakka.jms.consumer` section, or you may pass a `Config` instance which is resolved to a section of the same structure. Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala) { #consumer-settings } @@ -198,8 +198,8 @@ Java ## Request / Reply -The request / reply pattern can be implemented by streaming a @apidoc[JmsConsumer$] -to a @apidoc[JmsProducer$], +The request / reply pattern can be implemented by streaming a @apidoc[.jms.*.JmsConsumer$] +to a @apidoc[.jms.*.JmsProducer$], with a stage in between that extracts the `ReplyTo` and `CorrelationID` from the original message and adds them to the response. Scala diff --git a/docs/src/main/paradox/jms/producer.md b/docs/src/main/paradox/jms/producer.md index 3acd46dd5c..509ecfdc4b 100644 --- a/docs/src/main/paradox/jms/producer.md +++ b/docs/src/main/paradox/jms/producer.md @@ -38,8 +38,8 @@ The created @javadoc[ConnectionFactory](javax.jms.ConnectionFactory) is then use ### A `JmsMessage` sub-type sink -Use a case class with the subtype of @apidoc[JmsMessage] to wrap the messages you want to send and optionally set message specific properties or headers. -@apidoc[JmsProducer$] contains factory methods to facilitate the creation of sinks according to the message type. +Use a case class with the subtype of @apidoc[.jms.JmsMessage] to wrap the messages you want to send and optionally set message specific properties or headers. +@apidoc[.jms.*.JmsProducer$] contains factory methods to facilitate the creation of sinks according to the message type. Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #create-jms-sink } @@ -50,7 +50,7 @@ Java #### Setting JMS message properties -For every @apidoc[JmsMessage] you can set JMS message properties. +For every @apidoc[.jms.JmsMessage] you can set JMS message properties. Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #create-messages-with-properties } @@ -60,7 +60,7 @@ Java #### Setting JMS message header attributes -For every @apidoc[JmsMessage] you can set also JMS message headers. +For every @apidoc[.jms.JmsMessage] you can set also JMS message headers. Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #create-messages-with-headers } @@ -73,7 +73,7 @@ Java #### Text sinks -Create a sink, that accepts and forwards @apidoc[JmsTextMessage$]s to the JMS provider: +Create a sink, that accepts and forwards @apidoc[.jms.JmsTextMessage$]s to the JMS provider: Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #text-sink } @@ -83,7 +83,7 @@ Java #### Byte array sinks -Create a sink, that accepts and forwards @apidoc[JmsByteMessage$]s to the JMS provider. +Create a sink, that accepts and forwards @apidoc[.jms.JmsByteMessage$]s to the JMS provider. Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #bytearray-sink } @@ -94,7 +94,7 @@ Java #### Map message sink -Create a sink, that accepts and forwards @apidoc[JmsMapMessage$]s to the JMS provider: +Create a sink, that accepts and forwards @apidoc[.jms.JmsMapMessage$]s to the JMS provider: Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #map-sink } @@ -107,7 +107,7 @@ Java Create and configure ActiveMQ connection factory to support serialization. See [ActiveMQ Security](https://activemq.apache.org/objectmessage.html) for more information on this. -Create a sink, that accepts and forwards @apidoc[JmsObjectMessage$]s to the JMS provider: +Create a sink, that accepts and forwards @apidoc[.jms.JmsObjectMessage$]s to the JMS provider: Scala : @@snip [snip](/jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala) { #object-sink } diff --git a/jakarta-jms/src/main/resources/reference.conf b/jakarta-jms/src/main/resources/reference.conf new file mode 100644 index 0000000000..12f7d13253 --- /dev/null +++ b/jakarta-jms/src/main/resources/reference.conf @@ -0,0 +1,132 @@ +# Settings for the Alpakka JMS Jakarta connector +# +alpakka.jakarta-jms { + #connection-retry + # Connection Retry Settings + # these set the defaults for Consumer, Producer, and Browse settings + connection-retry { + # Time allowed to establish and start a connection. + connect-timeout = 10 seconds + # Wait time before retrying the connection the first time. + initial-retry = 100 millis + # Back-off factor for subsequent retries. + backoff-factor = 2 + # Back-off factor for subsequent retries. + max-backoff = 1 minute + # Maximum number of retries allowed. + # "infinite", or positive integer + max-retries = 10 + } + + #connection-retry + + #consumer + # Jms Consumer Settings + # sets default values + consumer { + # Configure connection retrying by providing settings for ConnectionRetrySettings. + connection-retry = ${alpakka.jakarta-jms.connection-retry} + # Credentials to connect to the JMS broker. + # credentials { + # username = "some text" + # password = "some text" + # } + # "off" to not use any credentials. + credentials = off + # Number of parallel sessions to use for receiving JMS messages. + session-count = 1 + # Buffer size for maximum number for messages read from JMS when there is no demand. + buffer-size = 100 + # JMS selector expression. + # See https://docs.oracle.com/cd/E19798-01/821-1841/bncer/index.html + # empty string for unset + selector = "" # optional + # Set an explicit acknowledge mode. + # (Consumers have specific defaults.) + # See eg. jakarta.jms.Session.AUTO_ACKNOWLEDGE + # Allowed values: "off", "auto", "client", "duplicates-ok", "session", integer value + acknowledge-mode = off + # Timeout for acknowledge. + # (Used by TX consumers.) + ack-timeout = 1 second + # For use with transactions, if true the stream fails if Alpakka rolls back the transaction + # when `ack-timeout` is hit. + fail-stream-on-ack-timeout = false + # Max interval before sending queued acknowledges back to the broker. (Used by AckSources.) + # max-ack-interval = 5 seconds + # Max number of acks queued by AckSource before they are sent to broker. (Unless MaxAckInterval is specified). + max-pending-acks = ${alpakka.jakarta-jms.consumer.buffer-size} + # How long the stage should preserve connection status events for the first subscriber before discarding them + connection-status-subscription-timeout = 5 seconds + } + #consumer + + #send-retry + # Send Retry Settings + # these set the defaults for Producer settings + send-retry { + # Wait time before retrying the first time. + initial-retry = 20 millis + # Back-off factor for subsequent retries. + backoff-factor = 1.5 + # Maximum back-off time allowed, after which all retries will happen after this delay. + max-backoff = 500 millis + # Maximum number of retries allowed. + # "infinite", or positive integer + max-retries = 10 + } + #send-retry + + # #producer + # Jms Producer Settings + # sets default values + producer { + # Configure connection retrying by providing settings for ConnectionRetrySettings. + connection-retry = ${alpakka.jakarta-jms.connection-retry} + # Configure re-sending by providing settings for SendRetrySettings. + send-retry = ${alpakka.jakarta-jms.send-retry} + # Credentials to connect to the JMS broker. + # credentials { + # username = "some text" + # password = "some text" + # } + # "off" to not use any credentials. + credentials = off + # Number of parallel sessions to use for sending JMS messages. + # Increasing the number of parallel sessions increases throughput at the cost of message ordering. + # While the messages may arrive out of order on the JMS broker, the producer flow outputs messages + # in the order they are received. + session-count = 1 + # Time messages should be kept on the JMS broker. + # This setting can be overridden on individual messages. + # "off" to not let messages expire. + time-to-live = off + # How long the stage should preserve connection status events for the first subscriber before discarding them + connection-status-subscription-timeout = 5 seconds + } + # #producer + + #browse + # Jms Browse Settings + # sets default values + browse { + # Configure connection retrying by providing settings for ConnectionRetrySettings. + connection-retry = ${alpakka.jakarta-jms.connection-retry} + # Credentials to connect to the JMS broker. + # credentials { + # username = "some text" + # password = "some text" + # } + # "off" to not use any credentials. + credentials = off + # JMS selector expression. + # See https://docs.oracle.com/cd/E19798-01/821-1841/bncer/index.html + # empty string for unset + selector = "" # optional + # Set an explicit acknowledge mode. + # See eg. jakarta.jms.Session.AUTO_ACKNOWLEDGE + # Allowed values: "auto", "client", "duplicates-ok", "session", integer value + acknowledge-mode = auto + } + #browse +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/AcknowledgeMode.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/AcknowledgeMode.scala new file mode 100644 index 0000000000..6d0de50c84 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/AcknowledgeMode.scala @@ -0,0 +1,59 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import jakarta.jms + +/** + * JMS acknowledge modes. + * See [[jakarta.jms.Connection#createSession-boolean-int-]] + */ +final class AcknowledgeMode(val mode: Int) { + override def equals(other: Any): Boolean = other match { + case that: AcknowledgeMode => this.mode == that.mode + case _ => false + } + override def hashCode: Int = mode + override def toString: String = s"AcknowledgeMode(${AcknowledgeMode.asString(this)})" +} + +object AcknowledgeMode { + val AutoAcknowledge: AcknowledgeMode = new AcknowledgeMode(jms.Session.AUTO_ACKNOWLEDGE) + val ClientAcknowledge: AcknowledgeMode = new AcknowledgeMode(jms.Session.CLIENT_ACKNOWLEDGE) + val DupsOkAcknowledge: AcknowledgeMode = new AcknowledgeMode(jms.Session.DUPS_OK_ACKNOWLEDGE) + val SessionTransacted: AcknowledgeMode = new AcknowledgeMode(jms.Session.SESSION_TRANSACTED) + + /** + * Interpret string to corresponding acknowledge mode. + */ + def from(s: String): AcknowledgeMode = s match { + case "auto" => AutoAcknowledge + case "client" => ClientAcknowledge + case "duplicates-ok" => DupsOkAcknowledge + case "session" => SessionTransacted + case other => + try { + val mode = other.toInt + new AcknowledgeMode(mode) + } catch { + case _: NumberFormatException => + throw new IllegalArgumentException( + s"can't read AcknowledgeMode '$other', (known are auto, client, duplicates-ok, session, or an integer value)" + ) + } + } + + /** + * Convert to a string presentation. + */ + def asString(mode: AcknowledgeMode): String = mode match { + case AutoAcknowledge => "auto" + case ClientAcknowledge => "client" + case DupsOkAcknowledge => "duplicates-ok" + case SessionTransacted => "session" + case other => other.mode.toString + } + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/ConnectionRetrySettings.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/ConnectionRetrySettings.scala new file mode 100644 index 0000000000..a3c16b49b2 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/ConnectionRetrySettings.scala @@ -0,0 +1,140 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import akka.actor.{ActorSystem, ClassicActorSystemProvider} +import akka.util.JavaDurationConverters._ +import com.typesafe.config.Config + +import scala.concurrent.duration._ + +/** + * When a connection to a broker cannot be established and errors out, or is timing out being established or + * started, the connection can be retried. + * All JMS publishers, consumers, and browsers are configured with connection retry settings. + */ +final class ConnectionRetrySettings private ( + val connectTimeout: scala.concurrent.duration.FiniteDuration, + val initialRetry: scala.concurrent.duration.FiniteDuration, + val backoffFactor: Double, + val maxBackoff: scala.concurrent.duration.FiniteDuration, + val maxRetries: Int +) { + + /** Time allowed to establish and start a connection. */ + def withConnectTimeout(value: scala.concurrent.duration.FiniteDuration): ConnectionRetrySettings = + copy(connectTimeout = value) + + /** Java API: Time allowed to establish and start a connection. */ + def withConnectTimeout(value: java.time.Duration): ConnectionRetrySettings = copy(connectTimeout = value.asScala) + + /** Wait time before retrying the first time. */ + def withInitialRetry(value: scala.concurrent.duration.FiniteDuration): ConnectionRetrySettings = + copy(initialRetry = value) + + /** Java API: Wait time before retrying the first time. */ + def withInitialRetry(value: java.time.Duration): ConnectionRetrySettings = copy(initialRetry = value.asScala) + + /** Back-off factor for subsequent retries. */ + def withBackoffFactor(value: Double): ConnectionRetrySettings = copy(backoffFactor = value) + + /** Maximum back-off time allowed, after which all retries will happen after this delay. */ + def withMaxBackoff(value: scala.concurrent.duration.FiniteDuration): ConnectionRetrySettings = + copy(maxBackoff = value) + + /** Java API: Maximum back-off time allowed, after which all retries will happen after this delay. */ + def withMaxBackoff(value: java.time.Duration): ConnectionRetrySettings = copy(maxBackoff = value.asScala) + + /** Maximum number of retries allowed. */ + def withMaxRetries(value: Int): ConnectionRetrySettings = copy(maxRetries = value) + + /** Do not limit the number of retries. */ + def withInfiniteRetries(): ConnectionRetrySettings = withMaxRetries(ConnectionRetrySettings.infiniteRetries) + + /** The wait time before the next attempt may be made. */ + def waitTime(retryNumber: Int): FiniteDuration = + (initialRetry * Math.pow(retryNumber, backoffFactor)).asInstanceOf[FiniteDuration].min(maxBackoff) + + private def copy( + connectTimeout: scala.concurrent.duration.FiniteDuration = connectTimeout, + initialRetry: scala.concurrent.duration.FiniteDuration = initialRetry, + backoffFactor: Double = backoffFactor, + maxBackoff: scala.concurrent.duration.FiniteDuration = maxBackoff, + maxRetries: Int = maxRetries + ): ConnectionRetrySettings = new ConnectionRetrySettings( + connectTimeout = connectTimeout, + initialRetry = initialRetry, + backoffFactor = backoffFactor, + maxBackoff = maxBackoff, + maxRetries = maxRetries + ) + + override def toString: String = + "ConnectionRetrySettings(" + + s"connectTimeout=${connectTimeout.toCoarsest}," + + s"initialRetry=${initialRetry.toCoarsest}," + + s"backoffFactor=$backoffFactor," + + s"maxBackoff=${maxBackoff.toCoarsest}," + + s"maxRetries=${if (maxRetries == ConnectionRetrySettings.infiniteRetries) "infinite" else maxRetries}" + + ")" +} + +object ConnectionRetrySettings { + val configPath = "alpakka.jakarta-jms.connection-retry" + + val infiniteRetries: Int = -1 + + /** + * Reads from the given config. + */ + def apply(c: Config): ConnectionRetrySettings = { + val connectTimeout = c.getDuration("connect-timeout").asScala + val initialRetry = c.getDuration("initial-retry").asScala + val backoffFactor = c.getDouble("backoff-factor") + val maxBackoff = c.getDuration("max-backoff").asScala + val maxRetries = if (c.getString("max-retries") == "infinite") infiniteRetries else c.getInt("max-retries") + new ConnectionRetrySettings( + connectTimeout, + initialRetry, + backoffFactor, + maxBackoff, + maxRetries + ) + } + + /** Java API: Reads from the given config. */ + def create(c: Config): ConnectionRetrySettings = apply(c) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.connection-retry`. + * + * @param actorSystem The actor system + */ + def apply(actorSystem: ActorSystem): ConnectionRetrySettings = + apply(actorSystem.settings.config.getConfig(configPath)) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.connection-retry`. + * + * @param actorSystem The actor system + */ + def apply(actorSystem: ClassicActorSystemProvider): ConnectionRetrySettings = + apply(actorSystem.classicSystem.settings.config.getConfig(configPath)) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.connection-retry`. + * + * @param actorSystem The actor system + */ + def create(actorSystem: ActorSystem): ConnectionRetrySettings = apply(actorSystem) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.connection-retry`. + * + * @param actorSystem The actor system + */ + def create(actorSystem: ClassicActorSystemProvider): ConnectionRetrySettings = apply(actorSystem) + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Credentials.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Credentials.scala new file mode 100644 index 0000000000..b65a1b4834 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Credentials.scala @@ -0,0 +1,76 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms +import com.typesafe.config.Config + +final class Credentials private ( + val username: String, + val password: String +) { + + def withUsername(value: String): Credentials = copy(username = value) + def withPassword(value: String): Credentials = copy(password = value) + + private def copy( + username: String = username, + password: String = password + ): Credentials = new Credentials( + username = username, + password = password + ) + + override def toString = + "Credentials(" + + s"username=$username," + + s"password=${"*" * password.length}" + + ")" + + override def equals(other: Any): Boolean = other match { + case that: Credentials => + java.util.Objects.equals(this.username, that.username) && + java.util.Objects.equals(this.password, that.password) + case _ => false + } + + override def hashCode(): Int = java.util.Objects.hash(username, password) +} + +object Credentials { + + /** + * Reads from the given config. + */ + def apply(c: Config): Credentials = { + val username = c.getString("username") + val password = c.getString("password") + new Credentials( + username, + password + ) + } + + /** + * Java API: Reads from the given config. + */ + def create(c: Config): Credentials = apply(c) + + /** Scala API */ + def apply( + username: String, + password: String + ): Credentials = new Credentials( + username, + password + ) + + /** Java API */ + def create( + username: String, + password: String + ): Credentials = new Credentials( + username, + password + ) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Destinations.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Destinations.scala new file mode 100644 index 0000000000..0a634b54c1 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Destinations.scala @@ -0,0 +1,65 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import jakarta.jms +import scala.compat.java8.FunctionConverters._ + +/** + * A destination to send to/receive from. + */ +sealed abstract class Destination { + val name: String + val create: jms.Session => jms.Destination +} + +object Destination { + + /** + * Create a [[Destination]] from a [[jakarta.jms.Destination]] + */ + def apply(destination: jms.Destination): Destination = destination match { + case queue: jms.Queue => Queue(queue.getQueueName) + case topic: jms.Topic => Topic(topic.getTopicName) + case _ => CustomDestination(destination.toString, _ => destination) + } + + /** + * Java API: Create a [[Destination]] from a [[jakarta.jms.Destination]] + */ + def createDestination(destination: jms.Destination): Destination = apply(destination) +} + +/** + * Specify a topic as destination to send to/receive from. + */ +final case class Topic(override val name: String) extends Destination { + override val create: jms.Session => jms.Destination = session => session.createTopic(name) +} + +/** + * Specify a durable topic destination to send to/receive from. + */ +final case class DurableTopic(name: String, subscriberName: String) extends Destination { + override val create: jms.Session => jms.Destination = session => session.createTopic(name) +} + +/** + * Specify a queue as destination to send to/receive from. + */ +final case class Queue(override val name: String) extends Destination { + override val create: jms.Session => jms.Destination = session => session.createQueue(name) +} + +/** + * Destination factory to create specific destinations to send to/receive from. + */ +final case class CustomDestination(override val name: String, override val create: jms.Session => jms.Destination) + extends Destination { + + /** Java API */ + def this(name: String, create: java.util.function.Function[jms.Session, jms.Destination]) = + this(name, create.asScala) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Envelopes.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Envelopes.scala new file mode 100644 index 0000000000..64bb4970bf --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Envelopes.scala @@ -0,0 +1,30 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import java.util.concurrent.atomic.AtomicBoolean + +import akka.stream.alpakka.jakartajms.impl.{JmsAckSession, JmsSession} +import jakarta.jms + +import scala.concurrent.{Future, Promise} + +case class AckEnvelope private[jakartajms] (message: jms.Message, private val jmsSession: JmsAckSession) { + + val processed = new AtomicBoolean(false) + + def acknowledge(): Unit = if (processed.compareAndSet(false, true)) jmsSession.ack(message) +} + +case class TxEnvelope private[jakartajms] (message: jms.Message, private val jmsSession: JmsSession) { + + private[this] val commitPromise = Promise[() => Unit]() + + private[jakartajms] val commitFuture: Future[() => Unit] = commitPromise.future + + def commit(): Unit = commitPromise.success(jmsSession.session.commit _) + + def rollback(): Unit = commitPromise.success(jmsSession.session.rollback _) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Headers.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Headers.scala new file mode 100644 index 0000000000..fe69a4f8db --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/Headers.scala @@ -0,0 +1,152 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms +import java.util.concurrent.TimeUnit + +import scala.concurrent.duration.Duration + +sealed trait JmsHeader { + + /** + * Indicates if this header must be set during the send() operation according to the JMS specification or as attribute of the jms message before. + */ + def usedDuringSend: Boolean +} + +final case class JmsCorrelationId(jmsCorrelationId: String) extends JmsHeader { + override val usedDuringSend = false +} + +object JmsCorrelationId { + + /** + * Java API: create [[JmsCorrelationId]] + */ + def create(correlationId: String) = JmsCorrelationId(correlationId) +} + +final case class JmsReplyTo(jmsDestination: Destination) extends JmsHeader { + override val usedDuringSend = false +} + +object JmsReplyTo { + + /** + * Reply to a queue with given name. + */ + def queue(name: String) = JmsReplyTo(Queue(name)) + + /** + * Reply to a topic with given name. + */ + def topic(name: String) = JmsReplyTo(Topic(name)) +} + +final case class JmsType(jmsType: String) extends JmsHeader { + override val usedDuringSend = false +} + +object JmsType { + + /** + * Java API: create [[JmsType]] + */ + def create(jmsType: String) = JmsType(jmsType) +} + +final case class JmsTimeToLive(timeInMillis: Long) extends JmsHeader { + override val usedDuringSend = true +} + +object JmsTimeToLive { + + /** + * Scala API: create [[JmsTimeToLive]] + */ + def apply(timeToLive: Duration): JmsTimeToLive = JmsTimeToLive(timeToLive.toMillis) + + /** + * Java API: create [[JmsTimeToLive]] + */ + def create(timeToLive: Long, unit: TimeUnit) = JmsTimeToLive(unit.toMillis(timeToLive)) +} + +/** + * Priority of a message can be between 0 (lowest) and 9 (highest). The default priority is 4. + */ +final case class JmsPriority(priority: Int) extends JmsHeader { + override val usedDuringSend = true +} + +object JmsPriority { + + /** + * Java API: create [[JmsPriority]] + */ + def create(priority: Int) = JmsPriority(priority) +} + +/** + * Delivery mode can be [[jakarta.jms.DeliveryMode.NON_PERSISTENT]] or [[jakarta.jms.DeliveryMode.PERSISTENT]] + */ +final case class JmsDeliveryMode(deliveryMode: Int) extends JmsHeader { + override val usedDuringSend = true +} + +object JmsDeliveryMode { + + /** + * Java API: create [[JmsDeliveryMode]] + */ + def create(deliveryMode: Int) = JmsDeliveryMode(deliveryMode) +} + +final case class JmsMessageId(jmsMessageId: String) extends JmsHeader { + override val usedDuringSend: Boolean = false +} + +object JmsMessageId { + + /** + * Java API: create [[JmsMessageId]] + */ + def create(messageId: String) = JmsMessageId(messageId) +} + +final case class JmsTimestamp(jmsTimestamp: Long) extends JmsHeader { + override val usedDuringSend: Boolean = false +} + +object JmsTimestamp { + + /** + * Java API: create [[JmsTimestamp]] + */ + def create(timestamp: Long) = JmsTimestamp(timestamp) +} + +final case class JmsRedelivered(jmsRedelivered: Boolean) extends JmsHeader { + override val usedDuringSend: Boolean = false +} + +object JmsRedelivered { + + /** + * Java API: create [[JmsRedelivered]] + */ + def create(redelivered: Boolean) = JmsRedelivered(redelivered) +} + +final case class JmsExpiration(jmsExpiration: Long) extends JmsHeader { + override val usedDuringSend: Boolean = false +} + +object JmsExpiration { + + /** + * Java API: create [[JmsExpiration]] + */ + def create(expiration: Long) = JmsExpiration(expiration) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsBrowseSettings.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsBrowseSettings.scala new file mode 100644 index 0000000000..5756e80007 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsBrowseSettings.scala @@ -0,0 +1,155 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import akka.actor.{ActorSystem, ClassicActorSystemProvider} +import com.typesafe.config.{Config, ConfigValueType} + +/** + * Settings for [[akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer.browse]] and [[akka.stream.alpakka.jakartajms.javadsl.JmsConsumer.browse]]. + */ +final class JmsBrowseSettings private ( + val connectionFactory: jakarta.jms.ConnectionFactory, + val connectionRetrySettings: ConnectionRetrySettings, + val destination: Option[Destination], + val credentials: Option[Credentials], + val selector: Option[String], + val acknowledgeMode: AcknowledgeMode +) { + + /** Factory to use for creating JMS connections. */ + def withConnectionFactory(value: jakarta.jms.ConnectionFactory): JmsBrowseSettings = copy(connectionFactory = value) + + /** Configure connection retrying. */ + def withConnectionRetrySettings(value: ConnectionRetrySettings): JmsBrowseSettings = + copy(connectionRetrySettings = value) + + /** Set a queue name to browse from. */ + def withQueue(name: String): JmsBrowseSettings = copy(destination = Some(Queue(name))) + + /** Set a JMS to subscribe to. Allows for custom handling with [[akka.stream.alpakka.jakartajms.CustomDestination CustomDestination]]. */ + def withDestination(value: Destination): JmsBrowseSettings = copy(destination = Option(value)) + + /** Set JMS broker credentials. */ + def withCredentials(value: Credentials): JmsBrowseSettings = copy(credentials = Option(value)) + + /** + * JMS selector expression. + * + * @see https://docs.oracle.com/cd/E19798-01/821-1841/bncer/index.html + */ + def withSelector(value: String): JmsBrowseSettings = copy(selector = Option(value)) + + /** Set an explicit acknowledge mode. (Consumers have specific defaults.) */ + def withAcknowledgeMode(value: AcknowledgeMode): JmsBrowseSettings = copy(acknowledgeMode = value) + + private def copy( + connectionFactory: jakarta.jms.ConnectionFactory = connectionFactory, + connectionRetrySettings: ConnectionRetrySettings = connectionRetrySettings, + destination: Option[Destination] = destination, + credentials: Option[Credentials] = credentials, + selector: Option[String] = selector, + acknowledgeMode: AcknowledgeMode = acknowledgeMode + ): JmsBrowseSettings = new JmsBrowseSettings( + connectionFactory = connectionFactory, + connectionRetrySettings = connectionRetrySettings, + destination = destination, + credentials = credentials, + selector = selector, + acknowledgeMode = acknowledgeMode + ) + + override def toString = + "JmsBrowseSettings(" + + s"connectionFactory=$connectionFactory," + + s"connectionRetrySettings=$connectionRetrySettings," + + s"destination=$destination," + + s"credentials=$credentials," + + s"selector=$selector," + + s"acknowledgeMode=${AcknowledgeMode.asString(acknowledgeMode)}" + + ")" +} + +object JmsBrowseSettings { + + val configPath = "alpakka.jakarta-jms.browse" + + /** + * Reads from the given config. + * + * @param c Config instance read configuration from + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(c: Config, connectionFactory: jakarta.jms.ConnectionFactory): JmsBrowseSettings = { + def getOption[A](path: String, read: Config => A): Option[A] = + if (c.hasPath(path) && (c.getValue(path).valueType() != ConfigValueType.STRING || c.getString(path) != "off")) + Some(read(c)) + else None + def getStringOption(path: String): Option[String] = + if (c.hasPath(path) && c.getString(path).nonEmpty) Some(c.getString(path)) else None + + val connectionRetrySettings = ConnectionRetrySettings(c.getConfig("connection-retry")) + val destination = None + val credentials = getOption("credentials", c => Credentials(c.getConfig("credentials"))) + val selector = getStringOption("selector") + val acknowledgeMode = AcknowledgeMode.from(c.getString("acknowledge-mode")) + new JmsBrowseSettings( + connectionFactory, + connectionRetrySettings, + destination, + credentials, + selector, + acknowledgeMode + ) + } + + /** + * Java API: Reads from the given config. + * + * @param c Config instance read configuration from + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(c: Config, connectionFactory: jakarta.jms.ConnectionFactory): JmsBrowseSettings = + apply(c, connectionFactory) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.browse`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(actorSystem: ActorSystem, connectionFactory: jakarta.jms.ConnectionFactory): JmsBrowseSettings = + apply(actorSystem.settings.config.getConfig(configPath), connectionFactory) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.browse`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(actorSystem: ClassicActorSystemProvider, + connectionFactory: jakarta.jms.ConnectionFactory): JmsBrowseSettings = + apply(actorSystem.classicSystem, connectionFactory) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.browse`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(actorSystem: ActorSystem, connectionFactory: jakarta.jms.ConnectionFactory): JmsBrowseSettings = + apply(actorSystem, connectionFactory) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.browse`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(actorSystem: ClassicActorSystemProvider, + connectionFactory: jakarta.jms.ConnectionFactory): JmsBrowseSettings = + apply(actorSystem.classicSystem, connectionFactory) + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsConsumerSettings.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsConsumerSettings.scala new file mode 100644 index 0000000000..42783bc699 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsConsumerSettings.scala @@ -0,0 +1,248 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import akka.actor.{ActorSystem, ClassicActorSystemProvider} +import akka.util.JavaDurationConverters._ +import com.typesafe.config.{Config, ConfigValueType} + +import scala.concurrent.duration.FiniteDuration + +/** + * Settings for [[akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer]] and [[akka.stream.alpakka.jakartajms.javadsl.JmsConsumer]]. + */ +final class JmsConsumerSettings private ( + val connectionFactory: jakarta.jms.ConnectionFactory, + val connectionRetrySettings: ConnectionRetrySettings, + val destination: Option[Destination], + val credentials: Option[Credentials], + val sessionCount: Int, + val bufferSize: Int, + val selector: Option[String], + val acknowledgeMode: Option[AcknowledgeMode], + val ackTimeout: scala.concurrent.duration.Duration, + val maxAckInterval: Option[scala.concurrent.duration.FiniteDuration], + val maxPendingAcks: Int, + val failStreamOnAckTimeout: Boolean, + val connectionStatusSubscriptionTimeout: scala.concurrent.duration.FiniteDuration +) extends akka.stream.alpakka.jakartajms.JmsSettings { + + /** Factory to use for creating JMS connections. */ + def withConnectionFactory(value: jakarta.jms.ConnectionFactory): JmsConsumerSettings = copy(connectionFactory = value) + + /** Configure connection retrying. */ + def withConnectionRetrySettings(value: ConnectionRetrySettings): JmsConsumerSettings = + copy(connectionRetrySettings = value) + + /** Set a queue name to read from. */ + def withQueue(name: String): JmsConsumerSettings = copy(destination = Some(Queue(name))) + + /** Set a topic name to listen to. */ + def withTopic(name: String): JmsConsumerSettings = copy(destination = Some(Topic(name))) + + /** Set a durable topic name to listen to, with a unique subscriber name. */ + def withDurableTopic(name: String, subscriberName: String): JmsConsumerSettings = + copy(destination = Some(DurableTopic(name, subscriberName))) + + /** Set a JMS to subscribe to. Allows for custom handling with [[akka.stream.alpakka.jakartajms.CustomDestination CustomDestination]]. */ + def withDestination(value: Destination): JmsConsumerSettings = copy(destination = Option(value)) + + /** Set JMS broker credentials. */ + def withCredentials(value: Credentials): JmsConsumerSettings = copy(credentials = Option(value)) + + /** + * Number of parallel sessions to use for receiving JMS messages. + */ + def withSessionCount(value: Int): JmsConsumerSettings = copy(sessionCount = value) + + /** Buffer size for maximum number for messages read from JMS when there is no demand. */ + def withBufferSize(value: Int): JmsConsumerSettings = copy(bufferSize = value) + + /** + * JMS selector expression. + * + * @see https://docs.oracle.com/cd/E19798-01/821-1841/bncer/index.html + */ + def withSelector(value: String): JmsConsumerSettings = copy(selector = Option(value)) + + /** Set an explicit acknowledge mode. (Consumers have specific defaults.) */ + def withAcknowledgeMode(value: AcknowledgeMode): JmsConsumerSettings = copy(acknowledgeMode = Option(value)) + + /** Timeout for acknowledge. (Used by TX consumers.) */ + def withAckTimeout(value: scala.concurrent.duration.Duration): JmsConsumerSettings = copy(ackTimeout = value) + + /** Java API: Timeout for acknowledge. (Used by TX consumers.) */ + def withAckTimeout(value: java.time.Duration): JmsConsumerSettings = copy(ackTimeout = value.asScala) + + /** Max interval before sending queued acknowledges back to the broker. (Used by AckSources.) */ + def withMaxAckInterval(value: scala.concurrent.duration.FiniteDuration): JmsConsumerSettings = + copy(maxAckInterval = Option(value)) + + /** Java API: Max interval before sending queued acknowledges back to the broker. (Used by AckSources.) */ + def withMaxAckInterval(value: java.time.Duration): JmsConsumerSettings = + copy(maxAckInterval = Option(value.asScala)) + + /** Max number of acks queued by AckSource before they are sent to broker. (Unless MaxAckInterval is specified) */ + def withMaxPendingAcks(value: Int): JmsConsumerSettings = copy(maxPendingAcks = value) + + /** + * For use with transactions, if true the stream fails if Alpakka rolls back the transaction when `ackTimeout` is hit. + */ + def withFailStreamOnAckTimeout(value: Boolean): JmsConsumerSettings = + if (failStreamOnAckTimeout == value) this else copy(failStreamOnAckTimeout = value) + + /** Timeout for connection status subscriber */ + def withConnectionStatusSubscriptionTimeout(value: FiniteDuration): JmsConsumerSettings = + copy(connectionStatusSubscriptionTimeout = value) + + /** Java API: Timeout for connection status subscriber */ + def withConnectionStatusSubscriptionTimeout(value: java.time.Duration): JmsConsumerSettings = + copy(connectionStatusSubscriptionTimeout = value.asScala) + + private def copy( + connectionFactory: jakarta.jms.ConnectionFactory = connectionFactory, + connectionRetrySettings: ConnectionRetrySettings = connectionRetrySettings, + destination: Option[Destination] = destination, + credentials: Option[Credentials] = credentials, + sessionCount: Int = sessionCount, + bufferSize: Int = bufferSize, + selector: Option[String] = selector, + acknowledgeMode: Option[AcknowledgeMode] = acknowledgeMode, + ackTimeout: scala.concurrent.duration.Duration = ackTimeout, + maxAckInterval: Option[scala.concurrent.duration.FiniteDuration] = maxAckInterval, + maxPendingAcks: Int = maxPendingAcks, + failStreamOnAckTimeout: Boolean = failStreamOnAckTimeout, + connectionStatusSubscriptionTimeout: scala.concurrent.duration.FiniteDuration = + connectionStatusSubscriptionTimeout + ): JmsConsumerSettings = new JmsConsumerSettings( + connectionFactory = connectionFactory, + connectionRetrySettings = connectionRetrySettings, + destination = destination, + credentials = credentials, + sessionCount = sessionCount, + bufferSize = bufferSize, + selector = selector, + acknowledgeMode = acknowledgeMode, + ackTimeout = ackTimeout, + maxAckInterval = maxAckInterval, + maxPendingAcks = maxPendingAcks, + failStreamOnAckTimeout = failStreamOnAckTimeout, + connectionStatusSubscriptionTimeout = connectionStatusSubscriptionTimeout + ) + + override def toString = + "JmsConsumerSettings(" + + s"connectionFactory=$connectionFactory," + + s"connectionRetrySettings=$connectionRetrySettings," + + s"destination=$destination," + + s"credentials=$credentials," + + s"sessionCount=$sessionCount," + + s"bufferSize=$bufferSize," + + s"selector=$selector," + + s"acknowledgeMode=${acknowledgeMode.map(m => AcknowledgeMode.asString(m))}," + + s"ackTimeout=${ackTimeout.toCoarsest}," + + s"maxAckInterval=${maxAckInterval.map(_.toCoarsest)}," + + s"maxPendingAcks=$maxPendingAcks," + + s"failStreamOnAckTimeout=$failStreamOnAckTimeout," + + s"connectionStatusSubscriptionTimeout=${connectionStatusSubscriptionTimeout.toCoarsest}" + + ")" +} + +object JmsConsumerSettings { + + val configPath = "alpakka.jakarta-jms.consumer" + + /** + * Reads from the given config. + * + * @param c Config instance read configuration from + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(c: Config, connectionFactory: jakarta.jms.ConnectionFactory): JmsConsumerSettings = { + def getOption[A](path: String, read: Config => A): Option[A] = + if (c.hasPath(path) && (c.getValue(path).valueType() != ConfigValueType.STRING || c.getString(path) != "off")) + Some(read(c)) + else None + + def getStringOption(path: String): Option[String] = + if (c.hasPath(path) && c.getString(path).nonEmpty) Some(c.getString(path)) else None + + val connectionRetrySettings = ConnectionRetrySettings(c.getConfig("connection-retry")) + val destination = None + val credentials = getOption("credentials", c => Credentials(c.getConfig("credentials"))) + val sessionCount = c.getInt("session-count") + val bufferSize = c.getInt("buffer-size") + val selector = getStringOption("selector") + val acknowledgeMode = getOption("acknowledge-mode", c => AcknowledgeMode.from(c.getString("acknowledge-mode"))) + val ackTimeout = c.getDuration("ack-timeout").asScala + val maxAckIntervalDuration = getOption("max-ack-interval", config => config.getDuration("max-ack-interval").asScala) + val maxAckInterval = maxAckIntervalDuration.map(duration => FiniteDuration(duration.length, duration.unit)) + val maxPendingAcks = c.getInt("max-pending-acks") + val failStreamOnAckTimeout = c.getBoolean("fail-stream-on-ack-timeout") + val connectionStatusSubscriptionTimeout = c.getDuration("connection-status-subscription-timeout").asScala + new JmsConsumerSettings( + connectionFactory, + connectionRetrySettings, + destination, + credentials, + sessionCount, + bufferSize, + selector, + acknowledgeMode, + ackTimeout, + maxAckInterval, + maxPendingAcks, + failStreamOnAckTimeout, + connectionStatusSubscriptionTimeout + ) + } + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.consumer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(actorSystem: ActorSystem, connectionFactory: jakarta.jms.ConnectionFactory): JmsConsumerSettings = + apply(actorSystem.settings.config.getConfig(configPath), connectionFactory) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.consumer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(actorSystem: ClassicActorSystemProvider, + connectionFactory: jakarta.jms.ConnectionFactory): JmsConsumerSettings = + apply(actorSystem.classicSystem, connectionFactory) + + /** + * Java API: Reads from the given config. + * + * @param c Config instance read configuration from + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(c: Config, connectionFactory: jakarta.jms.ConnectionFactory): JmsConsumerSettings = + apply(c, connectionFactory) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.consumer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(actorSystem: ActorSystem, connectionFactory: jakarta.jms.ConnectionFactory): JmsConsumerSettings = + apply(actorSystem, connectionFactory) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.consumer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(actorSystem: ClassicActorSystemProvider, + connectionFactory: jakarta.jms.ConnectionFactory): JmsConsumerSettings = + apply(actorSystem.classicSystem, connectionFactory) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsExceptions.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsExceptions.scala new file mode 100644 index 0000000000..aaa5d2aa69 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsExceptions.scala @@ -0,0 +1,62 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms +import jakarta.jms + +import scala.concurrent.TimeoutException +import scala.concurrent.duration.Duration +import scala.util.control.NoStackTrace + +/** + * Marker trait indicating that the exception thrown is persistent. The operation will always fail when retried. + */ +trait NonRetriableJmsException extends Exception + +case class UnsupportedMessagePropertyType(propertyName: String, propertyValue: Any, message: JmsEnvelope[_]) + extends Exception( + s"Jms property '$propertyName' has unknown type '${propertyValue.getClass.getName}'. " + + "Only primitive types and String are supported as property values." + ) + with NonRetriableJmsException + +@deprecated("Not used anywhere", "3.0.4") +case class NullMessageProperty(propertyName: String, message: JmsEnvelope[_]) + extends Exception( + s"null value was given for Jms property '$propertyName'." + ) + with NonRetriableJmsException + +case class UnsupportedMapMessageEntryType(entryName: String, entryValue: Any, message: JmsMapMessagePassThrough[_]) + extends Exception( + s"Jms MapMessage entry '$entryName' has unknown type '${entryValue.getClass.getName}'. " + + "Only primitive types, String, and Byte array are supported as entry values." + ) + with NonRetriableJmsException + +@deprecated("Not used anywhere", "3.0.4") +case class NullMapMessageEntry(entryName: String, message: JmsMapMessagePassThrough[_]) + extends Exception( + s"null value was given for Jms MapMessage entry '$entryName'." + ) + with NonRetriableJmsException + +case class UnsupportedMessageType(message: jms.Message) + extends Exception( + s"Can't convert a ${message.getClass.getName} to a JmsMessage" + ) + with NonRetriableJmsException + +case class ConnectionRetryException(message: String, cause: Throwable) extends Exception(message, cause) + +case object RetrySkippedOnMissingConnection + extends Exception("JmsProducer is not connected, send attempt skipped") + with NoStackTrace + +case object JmsNotConnected extends Exception("JmsConnector is not connected") with NoStackTrace + +case class JmsConnectTimedOut(message: String) extends TimeoutException(message) + +final class JmsTxAckTimeout(ackTimeout: Duration) + extends TimeoutException(s"The TxEnvelope didn't get committed or rolled back within ack-timeout ($ackTimeout)") diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsMessages.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsMessages.scala new file mode 100644 index 0000000000..fe0e51af6e --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsMessages.scala @@ -0,0 +1,1033 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import jakarta.jms + +import akka.NotUsed +import akka.stream.alpakka.jakartajms.impl.JmsMessageReader._ +import akka.util.ByteString +import scala.collection.JavaConverters._ +import scala.compat.java8.OptionConverters._ + +/** + * Base interface for messages handled by JmsProducers. Sub-classes support pass-through or use [[akka.NotUsed]] as type for pass-through. + * + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +sealed trait JmsEnvelope[+PassThrough] { + def headers: Set[JmsHeader] + + /** + * Java API. + */ + def getHeaders: java.util.Collection[JmsHeader] = headers.asJavaCollection + + def properties: Map[String, Any] + + /** + * Java API. + */ + def getProperties: java.util.Map[String, Any] = properties.asJava + + def destination: Option[Destination] + + /** + * Java API. + */ + def getDestination: java.util.Optional[Destination] = destination.asJava + + def passThrough: PassThrough + + /** + * Java API + */ + def getPassThrough: PassThrough = passThrough +} + +/** + * A stream element that does not produce a JMS message, but merely passes data through a `flexiFlow`. + * + * @param passThrough the data to pass through + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +final class JmsPassThrough[+PassThrough](val passThrough: PassThrough) extends JmsEnvelope[PassThrough] { + val properties: Map[String, Any] = Map.empty + val headers: Set[JmsHeader] = Set.empty + val destination: Option[Destination] = None +} + +/** + * A stream element that does not produce a JMS message, but merely passes data through a `flexiFlow`. + */ +object JmsPassThrough { + def apply[PassThrough](passThrough: PassThrough): JmsEnvelope[PassThrough] = + new JmsPassThrough[PassThrough](passThrough) + def create[PassThrough](passThrough: PassThrough): JmsEnvelope[PassThrough] = + new JmsPassThrough[PassThrough](passThrough) +} + +/** + * Marker trait for stream elements that contain pass-through data with properties. + */ +sealed trait JmsEnvelopeWithProperties[+PassThrough] extends JmsEnvelope[PassThrough] { + def withProperty(name: String, value: Any): JmsEnvelopeWithProperties[PassThrough] + + def withProperties(props: Map[String, Any]): JmsEnvelopeWithProperties[PassThrough] + + /** + * Java API. + */ + def withProperties(properties: java.util.Map[String, Object]): JmsEnvelopeWithProperties[PassThrough] +} + +/** + * Marker trait for stream elements that do not contain pass-through data. + */ +sealed trait JmsMessage extends JmsEnvelope[NotUsed] with JmsEnvelopeWithProperties[NotUsed] { + + def withHeader(jmsHeader: JmsHeader): JmsMessage + + def withHeaders(newHeaders: Set[JmsHeader]): JmsMessage + + def withoutDestination: JmsMessage + + def withProperty(name: String, value: Any): JmsMessage + + def withProperties(props: Map[String, Any]): JmsMessage + + /** + * Java API. + */ + def withProperties(properties: java.util.Map[String, Object]): JmsMessage +} + +object JmsMessage { + + /** + * Convert a [[jakarta.jms.Message]] to a [[JmsEnvelope]] with pass-through + */ + def apply[PassThrough](message: jms.Message, passThrough: PassThrough): JmsEnvelope[PassThrough] = message match { + case v: jms.BytesMessage => JmsByteMessage(v, passThrough) + case v: jms.MapMessage => JmsMapMessage(v, passThrough) + case v: jms.TextMessage => JmsTextMessage(v, passThrough) + case v: jms.ObjectMessage => JmsObjectMessage(v, passThrough) + case _ => throw UnsupportedMessageType(message) + } + + /** + * Convert a [[jakarta.jms.Message]] to a [[JmsMessage]] + */ + def apply(message: jms.Message): JmsMessage = message match { + case v: jms.BytesMessage => JmsByteMessage(v) + case v: jms.MapMessage => JmsMapMessage(v) + case v: jms.TextMessage => JmsTextMessage(v) + case v: jms.ObjectMessage => JmsObjectMessage(v) + case _ => throw UnsupportedMessageType(message) + } + + /** + * Java API: Convert a [[jakarta.jms.Message]] to a [[JmsEnvelope]] with pass-through + */ + def create[PassThrough](message: jms.Message, passThrough: PassThrough): JmsEnvelope[PassThrough] = + apply(message, passThrough) + + /** + * Java API: Convert a [[jakarta.jms.Message]] to a [[JmsMessage]] + */ + def create(message: jms.Message): JmsMessage = apply(message) +} + +// Scala 2.11 compatibility adapter for the Java API +object JmsMessageFactory { + + /** + * Java API: Convert a [[jakarta.jms.Message]] to a [[JmsEnvelope]] with pass-through + */ + def create[PassThrough](message: jms.Message, passThrough: PassThrough): JmsEnvelope[PassThrough] = + JmsMessage.apply(message, passThrough) + + /** + * Java API: Convert a [[jakarta.jms.Message]] to a [[JmsMessage]] + */ + def create(message: jms.Message): JmsMessage = JmsMessage.apply(message) +} + +/** + * Produces byte arrays to JMS, supports pass-through data. + * + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +sealed class JmsByteMessagePassThrough[+PassThrough] protected[jakartajms] ( + val bytes: Array[Byte], + val headers: Set[JmsHeader] = Set.empty, + val properties: Map[String, Any] = Map.empty, + val destination: Option[Destination] = None, + val passThrough: PassThrough +) extends JmsEnvelope[PassThrough] + with JmsEnvelopeWithProperties[PassThrough] { + + /** + * Add a Jms header e.g. JMSType + */ + def withHeader(jmsHeader: JmsHeader): JmsByteMessagePassThrough[PassThrough] = copy(headers = headers + jmsHeader) + + /** + * Add a property + */ + def withProperty(name: String, value: Any): JmsByteMessagePassThrough[PassThrough] = + copy(properties = properties + (name -> value)) + + def withProperties(props: Map[String, Any]): JmsByteMessagePassThrough[PassThrough] = + copy(properties = properties ++ props) + + /** + * Java API. + */ + def withProperties(map: java.util.Map[String, Object]): JmsByteMessagePassThrough[PassThrough] = + copy(properties = properties ++ map.asScala) + + def toQueue(name: String): JmsByteMessagePassThrough[PassThrough] = to(Queue(name)) + + def toTopic(name: String): JmsByteMessagePassThrough[PassThrough] = to(Topic(name)) + + def to(destination: Destination): JmsByteMessagePassThrough[PassThrough] = copy(destination = Some(destination)) + + def withoutDestination: JmsByteMessagePassThrough[PassThrough] = copy(destination = None) + + def withPassThrough[PassThrough2](passThrough: PassThrough2): JmsByteMessagePassThrough[PassThrough2] = + new JmsByteMessagePassThrough( + bytes, + headers, + properties, + destination, + passThrough + ) + + private def copy(bytes: Array[Byte] = bytes, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsByteMessagePassThrough[PassThrough] = + new JmsByteMessagePassThrough[PassThrough]( + bytes, + headers, + properties, + destination, + passThrough + ) +} + +/** + * Produces byte arrays to JMS. + */ +final class JmsByteMessage private (bytes: Array[Byte], + headers: Set[JmsHeader] = Set.empty, + properties: Map[String, Any] = Map.empty, + destination: Option[Destination] = None) + extends JmsByteMessagePassThrough[NotUsed](bytes, headers, properties, destination, NotUsed) + with JmsMessage { + + /** + * Add a Jms header e.g. JMSType + */ + override def withHeader(jmsHeader: JmsHeader): JmsByteMessage = copy(headers = headers + jmsHeader) + + override def withHeaders(newHeaders: Set[JmsHeader]): JmsByteMessage = copy(headers = headers ++ newHeaders) + + /** + * Add a property + */ + override def withProperty(name: String, value: Any): JmsByteMessage = + copy(properties = properties + (name -> value)) + + override def withProperties(props: Map[String, Any]): JmsByteMessage = + copy(properties = properties ++ props) + + /** + * Java API. + */ + override def withProperties(map: java.util.Map[String, Object]): JmsByteMessage = + copy(properties = properties ++ map.asScala) + + override def toQueue(name: String): JmsByteMessage = to(Queue(name)) + + override def toTopic(name: String): JmsByteMessage = to(Topic(name)) + + override def to(destination: Destination): JmsByteMessage = copy(destination = Some(destination)) + + override def withoutDestination: JmsByteMessage = copy(destination = None) + + private def copy(bytes: Array[Byte] = bytes, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsByteMessage = + new JmsByteMessage( + bytes, + headers, + properties, + destination + ) + +} + +/** + * Produces byte arrays to JMS. + */ +object JmsByteMessage { + + /** + * create a byte message with pass-through + */ + def apply[PassThrough](bytes: Array[Byte], passThrough: PassThrough): JmsByteMessagePassThrough[PassThrough] = + new JmsByteMessagePassThrough[PassThrough](bytes = bytes, passThrough = passThrough) + + /** + * create a byte message + */ + def apply(bytes: Array[Byte]) = new JmsByteMessage(bytes = bytes) + + /** + * Java API: create a byte message with pass-through + */ + def create[PassThrough](bytes: Array[Byte], passThrough: PassThrough): JmsByteMessagePassThrough[PassThrough] = + new JmsByteMessagePassThrough[PassThrough](bytes = bytes, passThrough = passThrough) + + /** + * Java API: create [[JmsByteMessage]] + */ + def create(bytes: Array[Byte]) = new JmsByteMessage(bytes = bytes) + + /** + * Create a byte message from a [[jakarta.jms.BytesMessage]] with pass-through + */ + def apply[PassThrough](message: jms.BytesMessage, passThrough: PassThrough): JmsByteMessagePassThrough[PassThrough] = + new JmsByteMessagePassThrough[PassThrough](readArray(message), + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_)), + passThrough) + + /** + * Create a byte message from a [[jakarta.jms.BytesMessage]] + */ + def apply(message: jms.BytesMessage): JmsByteMessage = + new JmsByteMessage(readArray(message), + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_))) + + /** + * Java API: Create a byte message from a [[jakarta.jms.BytesMessage]] with pass-through + */ + def create[PassThrough](message: jms.BytesMessage, passThrough: PassThrough): JmsByteMessagePassThrough[PassThrough] = + apply(message, passThrough) + + /** + * Java API: Create a byte message from a [[jakarta.jms.BytesMessage]] + */ + def create(message: jms.BytesMessage): JmsByteMessage = apply(message) +} + +/** + * Produces byte array messages to JMS from the incoming `ByteString`, supports pass-through data. + * + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +sealed class JmsByteStringMessagePassThrough[+PassThrough] protected[jakartajms] ( + val bytes: ByteString, + val headers: Set[JmsHeader] = Set.empty, + val properties: Map[String, Any] = Map.empty, + val destination: Option[Destination] = None, + val passThrough: PassThrough +) extends JmsEnvelope[PassThrough] + with JmsEnvelopeWithProperties[PassThrough] { + + /** + * Add a Jms header e.g. JMSType + */ + def withHeader(jmsHeader: JmsHeader): JmsByteStringMessagePassThrough[PassThrough] = + copy(headers = headers + jmsHeader) + + /** + * Add a property + */ + def withProperty(name: String, value: Any): JmsByteStringMessagePassThrough[PassThrough] = + copy(properties = properties + (name -> value)) + + def withProperties(props: Map[String, Any]): JmsByteStringMessagePassThrough[PassThrough] = + copy(properties = properties ++ props) + + /** + * Java API + */ + def withProperties(props: java.util.Map[String, Object]): JmsByteStringMessagePassThrough[PassThrough] = + copy(properties = properties ++ props.asScala) + + def toQueue(name: String): JmsByteStringMessagePassThrough[PassThrough] = to(Queue(name)) + + def toTopic(name: String): JmsByteStringMessagePassThrough[PassThrough] = to(Topic(name)) + + def to(destination: Destination): JmsByteStringMessagePassThrough[PassThrough] = copy(destination = Some(destination)) + + def withoutDestination: JmsByteStringMessagePassThrough[PassThrough] = copy(destination = None) + + def withPassThrough[PassThrough2](passThrough: PassThrough2): JmsByteStringMessagePassThrough[PassThrough2] = + new JmsByteStringMessagePassThrough( + bytes, + headers, + properties, + destination, + passThrough + ) + + private def copy(bytes: ByteString = bytes, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsByteStringMessagePassThrough[PassThrough] = + new JmsByteStringMessagePassThrough( + bytes, + headers, + properties, + destination, + passThrough + ) + +} + +/** + * Produces byte array messages to JMS from the incoming `ByteString`. + */ +final class JmsByteStringMessage private (bytes: ByteString, + headers: Set[JmsHeader] = Set.empty, + properties: Map[String, Any] = Map.empty, + destination: Option[Destination] = None) + extends JmsByteStringMessagePassThrough[NotUsed](bytes, headers, properties, destination, NotUsed) + with JmsMessage { + + /** + * Add a Jms header e.g. JMSType + */ + override def withHeader(jmsHeader: JmsHeader): JmsByteStringMessage = copy(headers = headers + jmsHeader) + + override def withHeaders(newHeaders: Set[JmsHeader]): JmsByteStringMessage = copy(headers = headers ++ newHeaders) + + /** + * Add a property + */ + override def withProperty(name: String, value: Any): JmsByteStringMessage = + copy(properties = properties + (name -> value)) + + override def withProperties(props: Map[String, Any]): JmsByteStringMessage = + copy(properties = properties ++ props) + + /** + * Java API. + */ + override def withProperties(map: java.util.Map[String, Object]): JmsByteStringMessage = + copy(properties = properties ++ map.asScala) + + override def toQueue(name: String): JmsByteStringMessage = to(Queue(name)) + + override def toTopic(name: String): JmsByteStringMessage = to(Topic(name)) + + override def to(destination: Destination): JmsByteStringMessage = copy(destination = Some(destination)) + + override def withoutDestination: JmsByteStringMessage = copy(destination = None) + + private def copy(bytes: ByteString = bytes, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsByteStringMessage = + new JmsByteStringMessage( + bytes, + headers, + properties, + destination + ) + +} + +/** + * Produces byte array messages to JMS from the incoming `ByteString`. + */ +object JmsByteStringMessage { + + /** + * Create a byte message from a ByteString with a pass-through attached + */ + def apply[PassThrough](byteString: ByteString, + passThrough: PassThrough): JmsByteStringMessagePassThrough[PassThrough] = + new JmsByteStringMessagePassThrough[PassThrough](byteString, passThrough = passThrough) + + /** + * Create a byte message from a ByteString + */ + def apply(byteString: ByteString) = new JmsByteStringMessage(byteString) + + /** + * Java API: Create a byte message from a ByteString with a pass-through attached + */ + def create[PassThrough](byteString: ByteString, + passThrough: PassThrough): JmsByteStringMessagePassThrough[PassThrough] = + new JmsByteStringMessagePassThrough[PassThrough](byteString, passThrough = passThrough) + + /** + * Java API: Create a byte message from a ByteString + */ + def create(byteString: ByteString) = apply(byteString) + + /** + * Create a byte message from a [[jakarta.jms.BytesMessage]] with pass-through + */ + def apply[PassThrough](message: jms.BytesMessage, + passThrough: PassThrough): JmsByteStringMessagePassThrough[PassThrough] = + new JmsByteStringMessagePassThrough[PassThrough](readBytes(message), + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_)), + passThrough) + + /** + * Create a byte message from a [[jakarta.jms.BytesMessage]] + */ + def apply(message: jms.BytesMessage): JmsByteStringMessage = + new JmsByteStringMessage(readBytes(message), + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_))) + + /** + * Java API: Create a byte message from a [[jakarta.jms.BytesMessage]] with pass-through + */ + def create[PassThrough](message: jms.BytesMessage, + passThrough: PassThrough): JmsByteStringMessagePassThrough[PassThrough] = + apply(message, passThrough) + + /** + * Java API: Create a byte message from a [[jakarta.jms.BytesMessage]] + */ + def create(message: jms.BytesMessage): JmsByteStringMessage = apply(message) + +} + +/** + * Produces map messages to JMS, supports pass-through data. + * + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +sealed class JmsMapMessagePassThrough[+PassThrough] protected[jakartajms] (val body: Map[String, Any], + val headers: Set[JmsHeader] = Set.empty, + val properties: Map[String, Any] = Map.empty, + val destination: Option[Destination] = None, + val passThrough: PassThrough) + extends JmsEnvelope[PassThrough] + with JmsEnvelopeWithProperties[PassThrough] { + + /** + * Add a Jms header e.g. JMSType + */ + def withHeader(jmsHeader: JmsHeader): JmsMapMessagePassThrough[PassThrough] = copy(headers = headers + jmsHeader) + + /** + * Add a property + */ + def withProperty(name: String, value: Any): JmsMapMessagePassThrough[PassThrough] = + copy(properties = properties + (name -> value)) + + def withProperties(props: Map[String, Any]): JmsMapMessagePassThrough[PassThrough] = + copy(properties = properties ++ props) + + def withProperties(props: java.util.Map[String, Object]): JmsMapMessagePassThrough[PassThrough] = + copy(properties = properties ++ props.asScala) + + def toQueue(name: String): JmsMapMessagePassThrough[PassThrough] = to(Queue(name)) + + def toTopic(name: String): JmsMapMessagePassThrough[PassThrough] = to(Topic(name)) + + def to(destination: Destination): JmsMapMessagePassThrough[PassThrough] = copy(destination = Some(destination)) + + def withoutDestination: JmsMapMessagePassThrough[PassThrough] = copy(destination = None) + + def withPassThrough[PassThrough2](passThrough: PassThrough2): JmsMapMessagePassThrough[PassThrough2] = + new JmsMapMessagePassThrough( + body, + headers, + properties, + destination, + passThrough + ) + + private def copy(body: Map[String, Any] = body, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsMapMessagePassThrough[PassThrough] = + new JmsMapMessagePassThrough( + body, + headers, + properties, + destination, + passThrough + ) + +} + +/** + * Produces map messages to JMS. + */ +final class JmsMapMessage(body: Map[String, Any], + headers: Set[JmsHeader] = Set.empty, + properties: Map[String, Any] = Map.empty, + destination: Option[Destination] = None) + extends JmsMapMessagePassThrough[NotUsed](body, headers, properties, destination, NotUsed) + with JmsMessage { + + /** + * Add a Jms header e.g. JMSType + */ + override def withHeader(jmsHeader: JmsHeader): JmsMapMessage = copy(headers = headers + jmsHeader) + + override def withHeaders(newHeaders: Set[JmsHeader]): JmsMapMessage = copy(headers = headers ++ newHeaders) + + /** + * Add a property + */ + override def withProperty(name: String, value: Any): JmsMapMessage = + copy(properties = properties + (name -> value)) + + override def withProperties(props: Map[String, Any]): JmsMapMessage = + copy(properties = properties ++ props) + + /** + * Java API. + */ + override def withProperties(map: java.util.Map[String, Object]): JmsMapMessage = + copy(properties = properties ++ map.asScala) + + override def toQueue(name: String): JmsMapMessage = to(Queue(name)) + + override def toTopic(name: String): JmsMapMessage = to(Topic(name)) + + override def to(destination: Destination): JmsMapMessage = copy(destination = Some(destination)) + + override def withoutDestination: JmsMapMessage = copy(destination = None) + + private def copy(body: Map[String, Any] = body, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsMapMessage = + new JmsMapMessage( + body, + headers, + properties, + destination + ) + +} + +/** + * Produces map messages to JMS. + */ +object JmsMapMessage { + + /** + * create a map message with a pass-through attached + */ + def apply[PassThrough](map: Map[String, Any], passThrough: PassThrough): JmsMapMessagePassThrough[PassThrough] = + new JmsMapMessagePassThrough[PassThrough](body = map, passThrough = passThrough) + + /** + * create a map message + */ + def apply(map: Map[String, Any]) = new JmsMapMessage(body = map) + + /** + * Java API: create a map message with a pass-through attached + */ + def create[PassThrough](map: java.util.Map[String, Any], + passThrough: PassThrough): JmsMapMessagePassThrough[PassThrough] = + new JmsMapMessagePassThrough[PassThrough](body = map.asScala.toMap, passThrough = passThrough) + + /** + * Java API: create map message + */ + def create(map: java.util.Map[String, Any]) = new JmsMapMessage(body = map.asScala.toMap) + + /** + * Create a map message from a [[jakarta.jms.MapMessage]] with pass-through + */ + def apply[PassThrough](message: jms.MapMessage, passThrough: PassThrough): JmsMapMessagePassThrough[PassThrough] = + new JmsMapMessagePassThrough[PassThrough](readMap(message), + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_)), + passThrough) + + /** + * Create a map message from a [[jakarta.jms.MapMessage]] + */ + def apply(message: jms.MapMessage): JmsMapMessage = + new JmsMapMessage(readMap(message), + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_))) + + /** + * Java API: Create a map message from a [[jakarta.jms.MapMessage]] with pass-through + */ + def create[PassThrough](message: jms.MapMessage, passThrough: PassThrough): JmsMapMessagePassThrough[PassThrough] = + apply(message, passThrough) + + /** + * Java API: Create a map message from a [[jakarta.jms.MapMessage]] + */ + def create(message: jms.MapMessage): JmsMapMessage = apply(message) +} + +/** + * Produces text messages to JMS, supports pass-through data. + * + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +sealed class JmsTextMessagePassThrough[+PassThrough] protected[jakartajms] ( + val body: String, + val headers: Set[JmsHeader] = Set.empty, + val properties: Map[String, Any] = Map.empty, + val destination: Option[Destination] = None, + val passThrough: PassThrough +) extends JmsEnvelope[PassThrough] + with JmsEnvelopeWithProperties[PassThrough] { + + /** + * Add a Jms header e.g. JMSType + */ + def withHeader(jmsHeader: JmsHeader): JmsTextMessagePassThrough[PassThrough] = copy(headers = headers + jmsHeader) + + /** + * Add a property + */ + def withProperty(name: String, value: Any): JmsTextMessagePassThrough[PassThrough] = + copy(properties = properties + (name -> value)) + + def withProperties(props: Map[String, Any]): JmsTextMessagePassThrough[PassThrough] = + copy(properties = properties ++ props) + + /** + * Java API + */ + def withProperties(props: java.util.Map[String, Object]): JmsTextMessagePassThrough[PassThrough] = + copy(properties = properties ++ props.asScala) + + def toQueue(name: String): JmsTextMessagePassThrough[PassThrough] = to(Queue(name)) + + def toTopic(name: String): JmsTextMessagePassThrough[PassThrough] = to(Topic(name)) + + def to(destination: Destination): JmsTextMessagePassThrough[PassThrough] = copy(destination = Some(destination)) + + def withoutDestination: JmsTextMessagePassThrough[PassThrough] = copy(destination = None) + + def withPassThrough[PassThrough2](passThrough: PassThrough2): JmsTextMessagePassThrough[PassThrough2] = + new JmsTextMessagePassThrough( + body, + headers, + properties, + destination, + passThrough + ) + + private def copy(body: String = body, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsTextMessagePassThrough[PassThrough] = + new JmsTextMessagePassThrough[PassThrough]( + body, + headers, + properties, + destination, + passThrough + ) + +} + +/** + * Produces text messages to JMS. + */ +final class JmsTextMessage private (body: String, + headers: Set[JmsHeader] = Set.empty, + properties: Map[String, Any] = Map.empty, + destination: Option[Destination] = None) + extends JmsTextMessagePassThrough[NotUsed](body, headers, properties, destination, NotUsed) + with JmsMessage { + + /** + * Add a Jms header e.g. JMSType + */ + override def withHeader(jmsHeader: JmsHeader): JmsTextMessage = copy(headers = headers + jmsHeader) + + override def withHeaders(newHeaders: Set[JmsHeader]): JmsTextMessage = copy(headers = headers ++ newHeaders) + + /** + * Add a property + */ + override def withProperty(name: String, value: Any): JmsTextMessage = copy(properties = properties + (name -> value)) + + override def withProperties(props: Map[String, Any]): JmsTextMessage = + copy(properties = properties ++ props) + + /** + * Java API. + */ + override def withProperties(map: java.util.Map[String, Object]): JmsTextMessage = + copy(properties = properties ++ map.asScala) + + override def toQueue(name: String): JmsTextMessage = to(Queue(name)) + + override def toTopic(name: String): JmsTextMessage = to(Topic(name)) + + override def to(destination: Destination): JmsTextMessage = copy(destination = Some(destination)) + + override def withoutDestination: JmsTextMessage = copy(destination = None) + + private def copy(body: String = body, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsTextMessage = new JmsTextMessage( + body, + headers, + properties, + destination + ) + +} + +/** + * Produces text messages to JMS. + */ +object JmsTextMessage { + + /** + * Create a text message with a pass-through attached + */ + def apply[PassThrough](body: String, passThrough: PassThrough): JmsTextMessagePassThrough[PassThrough] = + new JmsTextMessagePassThrough(body = body, passThrough = passThrough) + + /** + * Create a text message + */ + def apply(body: String): JmsTextMessage = new JmsTextMessage(body = body) + + /** + * Java API: Create a text message with a pass-through attached + */ + def create[PassThrough](body: String, passThrough: PassThrough): JmsTextMessagePassThrough[PassThrough] = + new JmsTextMessagePassThrough(body = body, passThrough = passThrough) + + /** + * Java API: create a text message + */ + def create(body: String): JmsTextMessage = new JmsTextMessage(body = body) + + /** + * Create a text message from a [[jakarta.jms.TextMessage]] with pass-through + */ + def apply[PassThrough](message: jms.TextMessage, passThrough: PassThrough): JmsTextMessagePassThrough[PassThrough] = + new JmsTextMessagePassThrough[PassThrough](message.getText, + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_)), + passThrough) + + /** + * Create a text message from a [[jakarta.jms.TextMessage]] + */ + def apply(message: jms.TextMessage): JmsTextMessage = + new JmsTextMessage(message.getText, + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_))) + + /** + * Java API: Create a text message from a [[jakarta.jms.TextMessage]] with pass-through + */ + def create[PassThrough](message: jms.TextMessage, passThrough: PassThrough): JmsTextMessagePassThrough[PassThrough] = + apply(message, passThrough) + + /** + * Java API: Create a text message from a [[jakarta.jms.TextMessage]] + */ + def create(message: jms.TextMessage): JmsTextMessage = apply(message) +} + +/** + * Produces object messages to JMS, supports pass-through data. + * + * @tparam PassThrough the type of data passed through the `flexiFlow` + */ +sealed class JmsObjectMessagePassThrough[+PassThrough] protected[jakartajms] ( + val serializable: java.io.Serializable, + val headers: Set[JmsHeader] = Set.empty, + val properties: Map[String, Any] = Map.empty, + val destination: Option[Destination] = None, + val passThrough: PassThrough +) extends JmsEnvelope[PassThrough] { + + /** + * Add a Jms header e.g. JMSType + */ + def withHeader(jmsHeader: JmsHeader): JmsObjectMessagePassThrough[PassThrough] = copy(headers = headers + jmsHeader) + + /** + * Add a property + */ + def withProperty(name: String, value: Any): JmsObjectMessagePassThrough[PassThrough] = + copy(properties = properties + (name -> value)) + + def withProperties(props: Map[String, Any]): JmsObjectMessagePassThrough[PassThrough] = + copy(properties = properties ++ props) + + def toQueue(name: String): JmsObjectMessagePassThrough[PassThrough] = to(Queue(name)) + + def toTopic(name: String): JmsObjectMessagePassThrough[PassThrough] = to(Topic(name)) + + def to(destination: Destination): JmsObjectMessagePassThrough[PassThrough] = copy(destination = Some(destination)) + + def withoutDestination: JmsObjectMessagePassThrough[PassThrough] = copy(destination = None) + + def withPassThrough[PassThrough2](passThrough: PassThrough2): JmsObjectMessagePassThrough[PassThrough2] = + new JmsObjectMessagePassThrough( + serializable, + headers, + properties, + destination, + passThrough + ) + + private def copy(serializable: java.io.Serializable = serializable, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsObjectMessagePassThrough[PassThrough] = + new JmsObjectMessagePassThrough( + serializable, + headers, + properties, + destination, + passThrough + ) + +} + +/** + * Produces object messages to JMS. + */ +final class JmsObjectMessage private (serializable: java.io.Serializable, + headers: Set[JmsHeader] = Set.empty, + properties: Map[String, Any] = Map.empty, + destination: Option[Destination] = None) + extends JmsObjectMessagePassThrough[NotUsed](serializable, headers, properties, destination, NotUsed) + with JmsMessage { + + /** + * Add a Jms header e.g. JMSType + */ + override def withHeader(jmsHeader: JmsHeader): JmsObjectMessage = copy(headers = headers + jmsHeader) + + override def withHeaders(newHeaders: Set[JmsHeader]): JmsObjectMessage = copy(headers = headers ++ newHeaders) + + /** + * Add a property + */ + override def withProperty(name: String, value: Any): JmsObjectMessage = + copy(properties = properties + (name -> value)) + + override def withProperties(props: Map[String, Any]): JmsObjectMessage = + copy(properties = properties ++ props) + + /** + * Java API. + */ + override def withProperties(map: java.util.Map[String, Object]): JmsObjectMessage = + copy(properties = properties ++ map.asScala) + + override def toQueue(name: String): JmsObjectMessage = to(Queue(name)) + + override def toTopic(name: String): JmsObjectMessage = to(Topic(name)) + + override def to(destination: Destination): JmsObjectMessage = copy(destination = Some(destination)) + + override def withoutDestination: JmsObjectMessage = copy(destination = None) + + private def copy(serializable: java.io.Serializable = serializable, + headers: Set[JmsHeader] = headers, + properties: Map[String, Any] = properties, + destination: Option[Destination] = destination): JmsObjectMessage = + new JmsObjectMessage( + serializable, + headers, + properties, + destination + ) + +} + +/** + * Produces object messages to JMS. + */ +object JmsObjectMessage { + + /** + * create an object message with a pass-through attached + */ + def apply[PassThrough](serializable: java.io.Serializable, + passThrough: PassThrough): JmsObjectMessagePassThrough[PassThrough] = + new JmsObjectMessagePassThrough[PassThrough](serializable, passThrough = passThrough) + + /** + * create an object message + */ + def apply(serializable: java.io.Serializable) = new JmsObjectMessage(serializable) + + /** + * Java API: create an object message with a pass-through attached + */ + def create[PassThrough](serializable: java.io.Serializable, + passThrough: PassThrough): JmsObjectMessagePassThrough[PassThrough] = + new JmsObjectMessagePassThrough[PassThrough](serializable, passThrough = passThrough) + + /** + * Java API: create an object message + */ + def create(serializable: Serializable) = new JmsObjectMessage(serializable) + + /** + * Create an object message with pass-through + */ + def apply[PassThrough](message: jms.ObjectMessage, + passThrough: PassThrough): JmsObjectMessagePassThrough[PassThrough] = + new JmsObjectMessagePassThrough[PassThrough](message.getObject, + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_)), + passThrough) + + /** + * Create an object message + */ + def apply(message: jms.ObjectMessage): JmsObjectMessage = + new JmsObjectMessage(message.getObject, + readHeaders(message), + readProperties(message), + Option(message.getJMSDestination).map(Destination(_))) + + /** + * Java API: Create an object message with pass-through + */ + def create[PassThrough](message: jms.ObjectMessage, + passThrough: PassThrough): JmsObjectMessagePassThrough[PassThrough] = + apply(message, passThrough) + + /** + * Java API: Create an object message + */ + def create(message: jms.ObjectMessage): JmsObjectMessage = apply(message) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsProducerSettings.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsProducerSettings.scala new file mode 100644 index 0000000000..86289794ac --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsProducerSettings.scala @@ -0,0 +1,192 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import akka.actor.{ActorSystem, ClassicActorSystemProvider} +import akka.util.JavaDurationConverters._ +import com.typesafe.config.{Config, ConfigValueType} + +import scala.concurrent.duration.FiniteDuration + +/** + * Settings for [[akka.stream.alpakka.jakartajms.scaladsl.JmsProducer]] and [[akka.stream.alpakka.jakartajms.javadsl.JmsProducer]]. + */ +final class JmsProducerSettings private ( + val connectionFactory: jakarta.jms.ConnectionFactory, + val connectionRetrySettings: ConnectionRetrySettings, + val sendRetrySettings: SendRetrySettings, + val destination: Option[Destination], + val credentials: Option[Credentials], + val sessionCount: Int, + val timeToLive: Option[scala.concurrent.duration.Duration], + val connectionStatusSubscriptionTimeout: scala.concurrent.duration.FiniteDuration +) extends akka.stream.alpakka.jakartajms.JmsSettings { + + /** Factory to use for creating JMS connections. */ + def withConnectionFactory(value: jakarta.jms.ConnectionFactory): JmsProducerSettings = copy(connectionFactory = value) + + /** Configure connection retrying. */ + def withConnectionRetrySettings(value: ConnectionRetrySettings): JmsProducerSettings = + copy(connectionRetrySettings = value) + + /** Configure re-sending. */ + def withSendRetrySettings(value: SendRetrySettings): JmsProducerSettings = copy(sendRetrySettings = value) + + /** Set a queue name as JMS destination. */ + def withQueue(name: String): JmsProducerSettings = copy(destination = Some(Queue(name))) + + /** Set a topic name as JMS destination. */ + def withTopic(name: String): JmsProducerSettings = copy(destination = Some(Topic(name))) + + /** Set a JMS destination. Allows for custom handling with [[akka.stream.alpakka.jakartajms.CustomDestination CustomDestination]]. */ + def withDestination(value: Destination): JmsProducerSettings = copy(destination = Option(value)) + + /** Set JMS broker credentials. */ + def withCredentials(value: Credentials): JmsProducerSettings = copy(credentials = Option(value)) + + /** + * Number of parallel sessions to use for sending JMS messages. + * Increasing the number of parallel sessions increases throughput at the cost of message ordering. + * While the messages may arrive out of order on the JMS broker, the producer flow outputs messages + * in the order they are received. + */ + def withSessionCount(value: Int): JmsProducerSettings = copy(sessionCount = value) + + /** + * Time messages should be kept on the JMS broker. This setting can be overridden on + * individual messages. If not set, messages will never expire. + */ + def withTimeToLive(value: scala.concurrent.duration.Duration): JmsProducerSettings = copy(timeToLive = Option(value)) + + /** + * Java API: Time messages should be kept on the JMS broker. This setting can be overridden on + * individual messages. If not set, messages will never expire. + */ + def withTimeToLive(value: java.time.Duration): JmsProducerSettings = copy(timeToLive = Option(value).map(_.asScala)) + + /** Timeout for connection status subscriber */ + def withConnectionStatusSubscriptionTimeout(value: FiniteDuration): JmsProducerSettings = + copy(connectionStatusSubscriptionTimeout = value) + + /** Java API: Timeout for connection status subscriber */ + def withConnectionStatusSubscriptionTimeout(value: java.time.Duration): JmsProducerSettings = + copy(connectionStatusSubscriptionTimeout = value.asScala) + + private def copy( + connectionFactory: jakarta.jms.ConnectionFactory = connectionFactory, + connectionRetrySettings: ConnectionRetrySettings = connectionRetrySettings, + sendRetrySettings: SendRetrySettings = sendRetrySettings, + destination: Option[Destination] = destination, + credentials: Option[Credentials] = credentials, + sessionCount: Int = sessionCount, + timeToLive: Option[scala.concurrent.duration.Duration] = timeToLive, + connectionStatusSubscriptionTimeout: scala.concurrent.duration.FiniteDuration = + connectionStatusSubscriptionTimeout + ): JmsProducerSettings = new JmsProducerSettings( + connectionFactory = connectionFactory, + connectionRetrySettings = connectionRetrySettings, + sendRetrySettings = sendRetrySettings, + destination = destination, + credentials = credentials, + sessionCount = sessionCount, + timeToLive = timeToLive, + connectionStatusSubscriptionTimeout = connectionStatusSubscriptionTimeout + ) + + override def toString = + "JmsProducerSettings(" + + s"connectionFactory=$connectionFactory," + + s"connectionRetrySettings=$connectionRetrySettings," + + s"sendRetrySettings=$sendRetrySettings," + + s"destination=$destination," + + s"credentials=$credentials," + + s"sessionCount=$sessionCount," + + s"timeToLive=${timeToLive.map(_.toCoarsest)}," + + s"connectionStatusSubscriptionTimeout=${connectionStatusSubscriptionTimeout.toCoarsest}" + + ")" +} + +object JmsProducerSettings { + + val configPath = "alpakka.jakarta-jms.producer" + + /** + * Reads from the given config. + * + * @param c Config instance read configuration from + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(c: Config, connectionFactory: jakarta.jms.ConnectionFactory): JmsProducerSettings = { + def getOption[A](path: String, read: Config => A): Option[A] = + if (c.hasPath(path) && (c.getValue(path).valueType() != ConfigValueType.STRING || c.getString(path) != "off")) + Some(read(c)) + else None + + val connectionRetrySettings = ConnectionRetrySettings(c.getConfig("connection-retry")) + val sendRetrySettings = SendRetrySettings(c.getConfig("send-retry")) + val credentials = getOption("credentials", c => Credentials(c.getConfig("credentials"))) + val sessionCount = c.getInt("session-count") + val timeToLive = getOption("time-to-live", _.getDuration("time-to-live").asScala) + val connectionStatusSubscriptionTimeout = c.getDuration("connection-status-subscription-timeout").asScala + new JmsProducerSettings( + connectionFactory, + connectionRetrySettings, + sendRetrySettings, + destination = None, + credentials, + sessionCount, + timeToLive, + connectionStatusSubscriptionTimeout + ) + } + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.producer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(actorSystem: ActorSystem, connectionFactory: jakarta.jms.ConnectionFactory): JmsProducerSettings = + apply(actorSystem.settings.config.getConfig(configPath), connectionFactory) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.producer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def apply(actorSystem: ClassicActorSystemProvider, + connectionFactory: jakarta.jms.ConnectionFactory): JmsProducerSettings = + apply(actorSystem.classicSystem, connectionFactory) + + /** + * Java API: Reads from the given config. + * + * @param c Config instance read configuration from + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(c: Config, connectionFactory: jakarta.jms.ConnectionFactory): JmsProducerSettings = + apply(c, connectionFactory) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.producer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(actorSystem: ActorSystem, connectionFactory: jakarta.jms.ConnectionFactory): JmsProducerSettings = + apply(actorSystem, connectionFactory) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.producer`. + * + * @param actorSystem The actor system + * @param connectionFactory Factory to use for creating JMS connections. + */ + def create(actorSystem: ClassicActorSystemProvider, + connectionFactory: jakarta.jms.ConnectionFactory): JmsProducerSettings = + apply(actorSystem.classicSystem, connectionFactory) + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsSettings.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsSettings.scala new file mode 100644 index 0000000000..58a579fcba --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/JmsSettings.scala @@ -0,0 +1,22 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import akka.annotation.DoNotInherit +import jakarta.jms + +/** + * Shared settings for all JMS stages. + * Used for internal standardization, and not meant to be used by user code. + */ +@DoNotInherit +trait JmsSettings { + def connectionFactory: jms.ConnectionFactory + def connectionRetrySettings: ConnectionRetrySettings + def destination: Option[Destination] + def credentials: Option[Credentials] + def sessionCount: Int + def connectionStatusSubscriptionTimeout: scala.concurrent.duration.FiniteDuration +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/SendRetrySettings.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/SendRetrySettings.scala new file mode 100644 index 0000000000..667a6e02ef --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/SendRetrySettings.scala @@ -0,0 +1,124 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import akka.actor.{ActorSystem, ClassicActorSystemProvider} +import com.typesafe.config.Config + +import scala.concurrent.duration._ +import akka.util.JavaDurationConverters._ + +/** + * When a connection to a broker starts failing, sending JMS messages will also fail. + * Those failed messages can be retried at the cost of potentially duplicating the failed messages. + */ +final class SendRetrySettings private (val initialRetry: scala.concurrent.duration.FiniteDuration, + val backoffFactor: Double, + val maxBackoff: scala.concurrent.duration.FiniteDuration, + val maxRetries: Int) { + + /** Wait time before retrying the first time. */ + def withInitialRetry(value: scala.concurrent.duration.FiniteDuration): SendRetrySettings = copy(initialRetry = value) + + /** Java API: Wait time before retrying the first time. */ + def withInitialRetry(value: java.time.Duration): SendRetrySettings = copy(initialRetry = value.asScala) + + /** Back-off factor for subsequent retries */ + def withBackoffFactor(value: Double): SendRetrySettings = copy(backoffFactor = value) + + /** Maximum back-off time allowed, after which all retries will happen after this delay. */ + def withMaxBackoff(value: scala.concurrent.duration.FiniteDuration): SendRetrySettings = copy(maxBackoff = value) + + /** Java API: Maximum back-off time allowed, after which all retries will happen after this delay. */ + def withMaxBackoff(value: java.time.Duration): SendRetrySettings = copy(maxBackoff = value.asScala) + + /** Maximum number of retries allowed. */ + def withMaxRetries(value: Int): SendRetrySettings = copy(maxRetries = value) + + /** Do not limit the number of retries. */ + def withInfiniteRetries(): SendRetrySettings = withMaxRetries(SendRetrySettings.infiniteRetries) + + /** The wait time before the next attempt may be made. */ + def waitTime(retryNumber: Int): FiniteDuration = + (initialRetry * Math.pow(retryNumber, backoffFactor)).asInstanceOf[FiniteDuration].min(maxBackoff) + + private def copy( + initialRetry: scala.concurrent.duration.FiniteDuration = initialRetry, + backoffFactor: Double = backoffFactor, + maxBackoff: scala.concurrent.duration.FiniteDuration = maxBackoff, + maxRetries: Int = maxRetries + ): SendRetrySettings = new SendRetrySettings( + initialRetry = initialRetry, + backoffFactor = backoffFactor, + maxBackoff = maxBackoff, + maxRetries = maxRetries + ) + + override def toString: String = + "SendRetrySettings(" + + s"initialRetry=${initialRetry.toCoarsest}," + + s"backoffFactor=$backoffFactor," + + s"maxBackoff=${maxBackoff.toCoarsest}," + + s"maxRetries=${if (maxRetries == SendRetrySettings.infiniteRetries) "infinite" else maxRetries}" + + ")" +} + +object SendRetrySettings { + val configPath = "alpakka.jakarta-jms.send-retry" + + val infiniteRetries: Int = -1 + + /** + * Reads from the given config. + */ + def apply(c: Config): SendRetrySettings = { + val initialRetry = c.getDuration("initial-retry").asScala + val backoffFactor = c.getDouble("backoff-factor") + val maxBackoff = c.getDuration("max-backoff").asScala + val maxRetries = if (c.getString("max-retries") == "infinite") infiniteRetries else c.getInt("max-retries") + new SendRetrySettings( + initialRetry, + backoffFactor, + maxBackoff, + maxRetries + ) + } + + /** + * Java API: Reads from given config. + */ + def create(c: Config): SendRetrySettings = apply(c) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.send-retry`. + * + * @param actorSystem The actor system + */ + def apply(actorSystem: ActorSystem): SendRetrySettings = + apply(actorSystem.settings.config.getConfig(configPath)) + + /** + * Reads from the default config provided by the actor system at `alpakka.jakarta-jms.send-retry`. + * + * @param actorSystem The actor system + */ + def apply(actorSystem: ClassicActorSystemProvider): SendRetrySettings = + apply(actorSystem.classicSystem) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.send-retry`. + * + * @param actorSystem The actor system + */ + def create(actorSystem: ActorSystem): SendRetrySettings = apply(actorSystem) + + /** + * Java API: Reads from the default config provided by the actor system at `alpakka.jakarta-jms.send-retry`. + * + * @param actorSystem The actor system + */ + def create(actorSystem: ClassicActorSystemProvider): SendRetrySettings = apply(actorSystem.classicSystem) + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/InternalConnectionState.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/InternalConnectionState.scala new file mode 100644 index 0000000000..3d6372ccc7 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/InternalConnectionState.scala @@ -0,0 +1,34 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.Done +import akka.annotation.InternalApi +import jakarta.jms + +import scala.concurrent.Future +import scala.util.Try + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] trait InternalConnectionState + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] object InternalConnectionState { + case object JmsConnectorDisconnected extends InternalConnectionState + case class JmsConnectorInitializing(connection: Future[jms.Connection], + attempt: Int, + backoffMaxed: Boolean, + sessions: Int) + extends InternalConnectionState + case class JmsConnectorConnected(connection: jms.Connection) extends InternalConnectionState + case class JmsConnectorStopping(completion: Try[Done]) extends InternalConnectionState + case class JmsConnectorStopped(completion: Try[Done]) extends InternalConnectionState +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsAckSourceStage.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsAckSourceStage.scala new file mode 100644 index 0000000000..d190ae3743 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsAckSourceStage.scala @@ -0,0 +1,82 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.impl.JmsConnector.FlushAcknowledgementsTimerKey +import akka.stream.stage.{GraphStageLogic, GraphStageWithMaterializedValue} +import akka.stream.{Attributes, Outlet, SourceShape} +import jakarta.jms + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsAckSourceStage(settings: JmsConsumerSettings, destination: Destination) + extends GraphStageWithMaterializedValue[SourceShape[AckEnvelope], JmsConsumerMatValue] { + + private val out = Outlet[AckEnvelope]("JmsSource.out") + + override def shape: SourceShape[AckEnvelope] = SourceShape[AckEnvelope](out) + + override def createLogicAndMaterializedValue( + inheritedAttributes: Attributes + ): (GraphStageLogic, JmsConsumerMatValue) = { + val logic = new JmsAckSourceStageLogic(inheritedAttributes) + (logic, logic.consumerControl) + } + + override protected def initialAttributes: Attributes = Attributes.name("JmsAckConsumer") + + private final class JmsAckSourceStageLogic(inheritedAttributes: Attributes) + extends SourceStageLogic[AckEnvelope](shape, out, settings, destination, inheritedAttributes) { self => + private val maxPendingAcks = settings.maxPendingAcks + private val maxAckInterval = settings.maxAckInterval + + protected def createSession(connection: jms.Connection, + createDestination: jms.Session => jakarta.jms.Destination): JmsAckSession = { + val session = + connection.createSession(false, settings.acknowledgeMode.getOrElse(AcknowledgeMode.ClientAcknowledge).mode) + new JmsAckSession(connection, session, createDestination(session), self.destination, maxPendingAcks) + } + + protected def pushMessage(msg: AckEnvelope): Unit = push(out, msg) + + override protected def onSessionOpened(jmsSession: JmsConsumerSession): Unit = + jmsSession match { + case session: JmsAckSession => + maxAckInterval.foreach { timeout => + scheduleWithFixedDelay(FlushAcknowledgementsTimerKey(session), timeout, timeout) + } + session + .createConsumer(settings.selector) + .map { consumer => + consumer.setMessageListener((message: jms.Message) => { + if (session.isListenerRunning) + try { + handleMessage.invoke(AckEnvelope(message, session)) + session.pendingAck += 1 + if (session.maxPendingAcksReached) { + session.ackBackpressure() + } + session.drainAcks() + } catch { + case e: jms.JMSException => + handleError.invoke(e) + } + }) + } + .onComplete(sessionOpenedCB.invoke) + + case _ => + throw new IllegalArgumentException( + "Session must be of type JMSAckSession, it is a " + + jmsSession.getClass.getName + ) + } + } + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsBrowseStage.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsBrowseStage.scala new file mode 100644 index 0000000000..d9d346f65c --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsBrowseStage.scala @@ -0,0 +1,67 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms.{Destination, JmsBrowseSettings} +import akka.stream.stage.{GraphStage, GraphStageLogic, OutHandler} +import akka.stream.{ActorAttributes, Attributes, Outlet, SourceShape} +import jakarta.jms + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsBrowseStage(settings: JmsBrowseSettings, queue: Destination) + extends GraphStage[SourceShape[jms.Message]] { + private val out = Outlet[jms.Message]("JmsBrowseStage.out") + val shape = SourceShape(out) + + override protected def initialAttributes: Attributes = + super.initialAttributes and Attributes.name("JmsBrowse") and ActorAttributes.IODispatcher + + override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = + new GraphStageLogic(shape) with OutHandler { + setHandler(out, this) + + var connection: jms.Connection = _ + var session: jms.Session = _ + var browser: jms.QueueBrowser = _ + var messages: java.util.Enumeration[jms.Message] = _ + + override def preStart(): Unit = { + val ackMode = settings.acknowledgeMode.mode + connection = settings.connectionFactory.createConnection() + connection.start() + + session = connection.createSession(false, ackMode) + browser = session.createBrowser(session.createQueue(queue.name), settings.selector.orNull) + messages = browser.getEnumeration.asInstanceOf[java.util.Enumeration[jms.Message]] + } + + override def postStop(): Unit = { + messages = null + if (browser ne null) { + browser.close() + browser = null + } + if (session ne null) { + session.close() + session = null + } + if (connection ne null) { + connection.close() + connection = null + } + } + + def onPull(): Unit = + if (messages.hasMoreElements) { + push(out, messages.nextElement()) + } else { + complete(out) + } + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsConnector.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsConnector.scala new file mode 100644 index 0000000000..1a9cdd62f2 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsConnector.scala @@ -0,0 +1,419 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import java.util.concurrent.atomic.AtomicReference + +import akka.{Done, NotUsed} +import akka.actor.ActorSystem +import akka.annotation.InternalApi +import akka.dispatch.ExecutionContexts +import akka.pattern.after +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.impl.InternalConnectionState._ +import akka.stream.scaladsl.{BroadcastHub, Keep, Sink, Source, SourceQueueWithComplete} +import akka.stream.stage.{AsyncCallback, StageLogging, TimerGraphStageLogic} +import akka.stream.{ActorAttributes, Attributes, OverflowStrategy} +import jakarta.jms + +import scala.concurrent.{ExecutionContext, Future, Promise} +import scala.util.control.NonFatal +import scala.util.{Failure, Success, Try} + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] trait JmsConnector[S <: JmsSession] extends TimerGraphStageLogic with StageLogging { + import JmsConnector._ + + implicit protected var ec: ExecutionContext = _ + + private var jmsSessions = Seq.empty[S] + + protected def destination: Destination + + protected def jmsSettings: JmsSettings + + protected def onSessionOpened(jmsSession: S): Unit = {} + + protected val fail: AsyncCallback[Throwable] = getAsyncCallback[Throwable](publishAndFailStage) + + private val connectionFailedCB = getAsyncCallback[Throwable](connectionFailed) + + private var connectionStateQueue: SourceQueueWithComplete[InternalConnectionState] = _ + + private val connectionStateSourcePromise = Promise[Source[InternalConnectionState, NotUsed]]() + + protected val connectionStateSource: Future[Source[InternalConnectionState, NotUsed]] = + connectionStateSourcePromise.future + + private var connectionState: InternalConnectionState = JmsConnectorDisconnected + + override def preStart(): Unit = { + // keep two elements since the time between initializing and connected can be very short. + // always drops the old state, and keeps the most current (two) state(s) in the queue. + val (queue, source) = + Source + .queue[InternalConnectionState](2, OverflowStrategy.dropHead) + .toMat(BroadcastHub.sink(1))(Keep.both) + .run()(this.materializer) + connectionStateQueue = queue + connectionStateSourcePromise.complete(Success(source)) + + // add subscription to purge queued connection status events after the configured timeout. + scheduleOnce(ConnectionStatusTimeout, jmsSettings.connectionStatusSubscriptionTimeout) + } + + protected def finishStop(): Unit = { + val update: InternalConnectionState => InternalConnectionState = { + case JmsConnectorStopping(completion) => JmsConnectorStopped(completion) + case stopped: JmsConnectorStopped => stopped + case current => + JmsConnectorStopped( + Failure(new IllegalStateException(s"Completing stage stop in unexpected state ${current.getClass}")) + ) + } + + closeSessions() + val previous = updateStateWith(update) + closeConnectionAsync(connection(previous)) + if (isTimerActive("connection-status-timeout")) drainConnectionState() + connectionStateQueue.complete() + } + + protected def publishAndFailStage(ex: Throwable): Unit = { + val previous = updateState(JmsConnectorStopping(Failure(ex))) + closeConnectionAsync(connection(previous)) + failStage(ex) + } + + protected def updateState(next: InternalConnectionState): InternalConnectionState = { + val update: InternalConnectionState => InternalConnectionState = { + case current: JmsConnectorStopping => current + case current: JmsConnectorStopped => current + case _ => next + } + updateStateWith(update) + } + + private def updateStateWith(f: InternalConnectionState => InternalConnectionState): InternalConnectionState = { + val last = connectionState + connectionState = f(last) + + // use type-based comparison to publish JmsConnectorInitializing only once. + if (last.getClass != connectionState.getClass) { + if (log.isDebugEnabled) + log.debug("updateStateWith {} -> {}", last.getClass.getSimpleName, connectionState.getClass.getSimpleName) + connectionStateQueue.offer(connectionState) + } + + last + } + + protected def connectionFailed(ex: Throwable): Unit = ex match { + case ex: jms.JMSSecurityException => + log.error( + ex, + "{} initializing connection failed, security settings are not properly configured for destination[{}]", + attributes.nameLifted.mkString, + destination.name + ) + publishAndFailStage(ex) + + case _: jms.JMSException | _: JmsConnectTimedOut => handleRetriableException(ex) + + case _ => + connectionState match { + case _: JmsConnectorStopping | _: JmsConnectorStopped => logStoppingException(ex) + case _ => + log.error(ex, "{} connection failed for destination[{}]", attributes.nameLifted.mkString, destination.name) + publishAndFailStage(ex) + } + } + + private def handleRetriableException(ex: Throwable): Unit = { + closeSessions() + + connectionState match { + case JmsConnectorInitializing(_, attempt, backoffMaxed, _) => + maybeReconnect(ex, attempt, backoffMaxed) + case JmsConnectorConnected(_) | JmsConnectorDisconnected => + maybeReconnect(ex, 0, backoffMaxed = false) + case _: JmsConnectorStopping | _: JmsConnectorStopped => logStoppingException(ex) + case other => + log.warning("received [{}] in connectionState={}", ex, connectionState) + } + } + + private def logStoppingException(ex: Throwable): Unit = + log.info("{} caught exception {} while stopping stage: {}", + attributes.nameLifted.mkString, + ex.getClass.getSimpleName, + ex.getMessage) + + private val onSession: AsyncCallback[S] = getAsyncCallback[S] { session => + jmsSessions :+= session + onSessionOpened(session) + } + + protected val sessionOpened: Try[Unit] => Unit = { + case Success(_) => + connectionState match { + case init @ JmsConnectorInitializing(c, _, _, sessions) => + if (sessions + 1 == jmsSettings.sessionCount) { + c.foreach { c => + updateState(JmsConnectorConnected(c)) + log.info("{} connected", attributes.nameLifted.mkString) + } + } else { + updateState(init.copy(sessions = sessions + 1)) + } + case s => () + } + + case Failure(ex: jms.JMSException) => + updateState(JmsConnectorDisconnected) match { + case JmsConnectorInitializing(c, attempt, backoffMaxed, _) => + closeConnectionAsync(c) + maybeReconnect(ex, attempt, backoffMaxed) + case _ => () + } + + case Failure(ex) => + log.error(ex, + "{} initializing connection failed for destination[{}]", + attributes.nameLifted.mkString, + destination.name) + publishAndFailStage(ex) + } + + protected val sessionOpenedCB: AsyncCallback[Try[Unit]] = getAsyncCallback[Try[Unit]](sessionOpened) + + private def maybeReconnect(ex: Throwable, attempt: Int, backoffMaxed: Boolean): Unit = { + val retrySettings = jmsSettings.connectionRetrySettings + import retrySettings._ + val nextAttempt = attempt + 1 + if (maxRetries >= 0 && nextAttempt > maxRetries) { + val exception = + if (maxRetries == 0) ex + else ConnectionRetryException(s"Could not establish connection after $maxRetries retries.", ex) + log.error(exception, + "{} initializing connection failed for destination[{}]", + attributes.nameLifted.mkString, + destination.name) + publishAndFailStage(exception) + } else { + val status = updateState(JmsConnectorDisconnected) + closeConnectionAsync(connection(status)) + val delay = if (backoffMaxed) maxBackoff else waitTime(nextAttempt) + val backoffNowMaxed = backoffMaxed || delay == maxBackoff + scheduleOnce(AttemptConnect(nextAttempt, backoffNowMaxed), delay) + } + } + + override def onTimer(timerKey: Any): Unit = timerKey match { + case FlushAcknowledgementsTimerKey(session) => + session.drainAcks() + case AttemptConnect(attempt, backoffMaxed) => + log.info("{} retries connecting, attempt {}", attributes.nameLifted.mkString, attempt) + initSessionAsync(attempt, backoffMaxed) + case ConnectionStatusTimeout => drainConnectionState() + case _ => () + } + + private def drainConnectionState(): Unit = + Source.future(connectionStateSource).flatMapConcat(identity).runWith(Sink.ignore)(this.materializer) + + protected def executionContext(attributes: Attributes): ExecutionContext = { + val dispatcherId = (attributes.get[ActorAttributes.Dispatcher](ActorAttributes.IODispatcher) match { + case ActorAttributes.Dispatcher("") => + ActorAttributes.IODispatcher + case d => d + }) match { + case d @ ActorAttributes.IODispatcher => + // this one is not a dispatcher id, but is a config path pointing to the dispatcher id + materializer.system.settings.config.getString(d.dispatcher) + case d => d.dispatcher + } + + materializer.system.dispatchers.lookup(dispatcherId) + } + + protected def createSession(connection: jms.Connection, createDestination: jms.Session => jms.Destination): S + + protected def initSessionAsync(attempt: Int = 0, backoffMaxed: Boolean = false): Unit = { + val allSessions = openSessions(attempt, backoffMaxed) + allSessions.failed.foreach(connectionFailedCB.invoke)(ExecutionContexts.parasitic) + // wait for all sessions to successfully initialize before invoking the onSession callback. + // reduces flakiness (start, consume, then crash) at the cost of increased latency of startup. + allSessions.foreach(_.foreach(onSession.invoke)) + } + + protected def closeConnection(connection: jms.Connection): Unit = { + try { + // deregister exception listener to clear reference from JMS client to the Akka stage + connection.setExceptionListener(null) + } catch { + case _: jms.JMSException => // ignore + } + try { + connection.close() + log.debug("JMS connection {} closed", connection) + } catch { + case NonFatal(e) => log.warning("Error closing JMS connection {}: {}", connection, e) + } + } + + protected def closeConnectionAsync(eventualConnection: Future[jms.Connection]): Future[Done] = + eventualConnection.map(closeConnection).map(_ => Done) + + protected def closeSessions(): Unit = { + jmsSessions.foreach(closeSession) + jmsSessions = Seq.empty + } + + protected def closeSessionsAsync(): Future[Unit] = { + val closing = Future + .sequence { + jmsSessions.map(s => Future { closeSession(s) }) + } + .flatMap(_ => Future.unit) + + jmsSessions = Seq.empty + closing + } + + private def closeSession(s: S): Unit = { + try { + cancelAckTimers(s) + s.closeSession() + } catch { + case NonFatal(e) => log.error(e, "Error closing jms session") + } + } + + protected def abortSessionsAsync(): Future[Unit] = { + val aborting = Future + .sequence { + jmsSessions.map { s => + Future { + try { + cancelAckTimers(s) + s.abortSession() + } catch { + case NonFatal(e) => log.error(e, "Error aborting jms session") + } + } + } + } + .map(_ => ()) + jmsSessions = Seq.empty + aborting + } + + private def cancelAckTimers(s: JmsSession): Unit = s match { + case session: JmsAckSession => + cancelTimer(FlushAcknowledgementsTimerKey(session)) + case _ => () + } + + def startConnection: Boolean + + private def openSessions(attempt: Int, backoffMaxed: Boolean): Future[Seq[S]] = { + val eventualConnection = openConnection(attempt, backoffMaxed) + eventualConnection.flatMap { connection => + val sessionFutures = + for (_ <- 0 until jmsSettings.sessionCount) + yield Future(createSession(connection, destination.create)) + Future.sequence(sessionFutures) + }(ExecutionContexts.parasitic) + } + + private def openConnection(attempt: Int, backoffMaxed: Boolean): Future[jms.Connection] = { + implicit val system: ActorSystem = materializer.system + val jmsConnection = openConnectionAttempt(startConnection) + updateState(JmsConnectorInitializing(jmsConnection, attempt, backoffMaxed, 0)) + jmsConnection.map { connection => + connection.setExceptionListener(new jms.ExceptionListener { + override def onException(ex: jms.JMSException): Unit = { + closeConnection(connection) + connectionFailedCB.invoke(ex) + } + }) + connection + } + } + + private def openConnectionAttempt(startConnection: Boolean)(implicit system: ActorSystem): Future[jms.Connection] = { + val factory = jmsSettings.connectionFactory + val connectionRef = new AtomicReference[Option[jms.Connection]](None) + + // status is also the decision point between the two futures below which one will win. + val status = new AtomicReference[ConnectionAttemptStatus](Connecting) + + val connectionFuture = Future { + val connection = jmsSettings.credentials match { + case Some(c: Credentials) => factory.createConnection(c.username, c.password) + case _ => factory.createConnection() + } + if (status.get == Connecting) { // `TimedOut` can be set at any point. So we have to check whether to continue. + connectionRef.set(Some(connection)) + if (startConnection) connection.start() + } + // ... and close if the connection is not to be used, don't return the connection + if (!status.compareAndSet(Connecting, Connected)) { + connectionRef.get.foreach(closeConnection) + connectionRef.set(None) + throw JmsConnectTimedOut("Received timed out signal trying to establish connection") + } else connection + } + + val connectTimeout = jmsSettings.connectionRetrySettings.connectTimeout + val timeoutFuture = after(connectTimeout, system.scheduler) { + // Even if the timer goes off, the connection may already be good. We use the + // status field and an atomic compareAndSet to see whether we should indeed time out, or just return + // the connection. In this case it does not matter which future returns. Both will have the right answer. + if (status.compareAndSet(Connecting, TimedOut)) { + connectionRef.get.foreach(closeConnection) + connectionRef.set(None) + Future.failed( + JmsConnectTimedOut( + s"Timed out after $connectTimeout trying to establish connection. " + + "Please see ConnectionRetrySettings.connectTimeout" + ) + ) + } else + connectionRef.get match { + case Some(connection) => Future.successful(connection) + case None => Future.failed(new IllegalStateException("BUG: Connection reference not set when connected")) + } + } + + Future.firstCompletedOf(Iterator(connectionFuture, timeoutFuture))(ExecutionContexts.parasitic) + } +} + +/** + * Internal API. + */ +@InternalApi +object JmsConnector { + + sealed trait ConnectionAttemptStatus + case object Connecting extends ConnectionAttemptStatus + case object Connected extends ConnectionAttemptStatus + case object TimedOut extends ConnectionAttemptStatus + + final case class AttemptConnect(attempt: Int, backoffMaxed: Boolean) + final case class FlushAcknowledgementsTimerKey(jmsSession: JmsAckSession) + case object ConnectionStatusTimeout + + def connection: InternalConnectionState => Future[jms.Connection] = { + case InternalConnectionState.JmsConnectorInitializing(c, _, _, _) => c + case InternalConnectionState.JmsConnectorConnected(c) => Future.successful(c) + case _ => Future.failed(JmsNotConnected) + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsConsumerStage.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsConsumerStage.scala new file mode 100644 index 0000000000..7a28bd6744 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsConsumerStage.scala @@ -0,0 +1,68 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import java.util.concurrent.Semaphore + +import akka.annotation.InternalApi +import akka.stream._ +import akka.stream.alpakka.jakartajms._ +import akka.stream.stage._ +import jakarta.jms + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsConsumerStage(settings: JmsConsumerSettings, destination: Destination) + extends GraphStageWithMaterializedValue[SourceShape[jms.Message], JmsConsumerMatValue] { + + private val out = Outlet[jms.Message]("JmsConsumer.out") + + override protected def initialAttributes: Attributes = Attributes.name("JmsConsumer") + + override def shape: SourceShape[jms.Message] = SourceShape[jms.Message](out) + + override def createLogicAndMaterializedValue( + inheritedAttributes: Attributes + ): (GraphStageLogic, JmsConsumerMatValue) = { + val logic = new JmsConsumerStageLogic(inheritedAttributes) + (logic, logic.consumerControl) + } + + private final class JmsConsumerStageLogic(inheritedAttributes: Attributes) + extends SourceStageLogic[jms.Message](shape, out, settings, destination, inheritedAttributes) { self => + + private val bufferSize = (settings.bufferSize + 1) * settings.sessionCount + + private val backpressure = new Semaphore(bufferSize) + + protected def createSession(connection: jms.Connection, + createDestination: jms.Session => jakarta.jms.Destination): JmsConsumerSession = { + val session = + connection.createSession(false, settings.acknowledgeMode.getOrElse(AcknowledgeMode.AutoAcknowledge).mode) + + new JmsConsumerSession(connection, session, createDestination(session), self.destination) + } + + protected def pushMessage(msg: jms.Message): Unit = { + push(out, msg) + backpressure.release() + } + + override protected def onSessionOpened(jmsSession: JmsConsumerSession): Unit = + jmsSession + .createConsumer(settings.selector) + .map { consumer => + consumer.setMessageListener(new jms.MessageListener { + def onMessage(message: jms.Message): Unit = { + backpressure.acquire() + handleMessage.invoke(message) + } + }) + } + .onComplete(sessionOpenedCB.invoke) + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsInternalMatValues.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsInternalMatValues.scala new file mode 100644 index 0000000000..f925c2e612 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsInternalMatValues.scala @@ -0,0 +1,22 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.NotUsed +import akka.annotation.InternalApi +import akka.stream.KillSwitch +import akka.stream.scaladsl.Source + +/** + * Internal API. + */ +@InternalApi private[jakartajms] trait JmsProducerMatValue { + def connected: Source[InternalConnectionState, NotUsed] +} + +/** + * Internal API. + */ +@InternalApi private[jakartajms] trait JmsConsumerMatValue extends KillSwitch with JmsProducerMatValue diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageProducer.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageProducer.scala new file mode 100644 index 0000000000..a5a69562a3 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageProducer.scala @@ -0,0 +1,135 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms._ +import jakarta.jms + +/** + * Internal API. + */ +@InternalApi +private class JmsMessageProducer(jmsProducer: jms.MessageProducer, jmsSession: JmsProducerSession, val epoch: Int) { + + private val defaultDestination = jmsSession.jmsDestination + + private val destinationCache = new SoftReferenceCache[Destination, jms.Destination]() + + def send(elem: JmsEnvelope[_]): Unit = { + val message: jms.Message = createMessage(elem) + populateMessageProperties(message, elem) + + val (sendHeaders, headersBeforeSend: Set[JmsHeader]) = elem.headers.partition(_.usedDuringSend) + populateMessageHeader(message, headersBeforeSend) + + val deliveryMode = sendHeaders + .collectFirst { case x: JmsDeliveryMode => x.deliveryMode } + .getOrElse(jmsProducer.getDeliveryMode) + + val priority = sendHeaders + .collectFirst { case x: JmsPriority => x.priority } + .getOrElse(jmsProducer.getPriority) + + val timeToLive = sendHeaders + .collectFirst { case x: JmsTimeToLive => x.timeInMillis } + .getOrElse(jmsProducer.getTimeToLive) + + val destination = elem.destination match { + case Some(messageDestination) => lookup(messageDestination) + case None => defaultDestination + } + jmsProducer.send(destination, message, deliveryMode, priority, timeToLive) + } + + private def lookup(dest: Destination) = destinationCache.lookup(dest, dest.create(jmsSession.session)) + + private[jakartajms] def createMessage(element: JmsEnvelope[_]): jms.Message = + element match { + + case textMessage: JmsTextMessagePassThrough[_] => jmsSession.session.createTextMessage(textMessage.body) + + case byteMessage: JmsByteMessagePassThrough[_] => + val newMessage = jmsSession.session.createBytesMessage() + newMessage.writeBytes(byteMessage.bytes) + newMessage + + case byteStringMessage: JmsByteStringMessagePassThrough[_] => + val newMessage = jmsSession.session.createBytesMessage() + newMessage.writeBytes(byteStringMessage.bytes.toArray) + newMessage + + case mapMessage: JmsMapMessagePassThrough[_] => + val newMessage = jmsSession.session.createMapMessage() + populateMapMessage(newMessage, mapMessage) + newMessage + + case objectMessage: JmsObjectMessagePassThrough[_] => + jmsSession.session.createObjectMessage(objectMessage.serializable) + + case pt: JmsPassThrough[_] => throw new IllegalArgumentException("can't create message for JmsPassThrough") + + } + + private[jakartajms] def populateMessageProperties(message: jakarta.jms.Message, jmsMessage: JmsEnvelope[_]): Unit = + jmsMessage.properties.foreach { + case (key, v) => + v match { + case v: String => message.setStringProperty(key, v) + case v: Int => message.setIntProperty(key, v) + case v: Boolean => message.setBooleanProperty(key, v) + case v: Byte => message.setByteProperty(key, v) + case v: Short => message.setShortProperty(key, v) + case v: Float => message.setFloatProperty(key, v) + case v: Long => message.setLongProperty(key, v) + case v: Double => message.setDoubleProperty(key, v) + case v: Array[Byte] => message.setObjectProperty(key, v) + case null => message.setObjectProperty(key, null) + case _ => throw UnsupportedMessagePropertyType(key, v, jmsMessage) + } + } + + private def populateMapMessage(message: jakarta.jms.MapMessage, jmsMessage: JmsMapMessagePassThrough[_]): Unit = + jmsMessage.body.foreach { + case (key, v) => + v match { + case v: String => message.setString(key, v) + case v: Int => message.setInt(key, v) + case v: Boolean => message.setBoolean(key, v) + case v: Byte => message.setByte(key, v) + case v: Short => message.setShort(key, v) + case v: Float => message.setFloat(key, v) + case v: Long => message.setLong(key, v) + case v: Double => message.setDouble(key, v) + case v: Array[Byte] => message.setBytes(key, v) + case null => message.setObject(key, v) + case _ => throw UnsupportedMapMessageEntryType(key, v, jmsMessage) + } + } + + private def populateMessageHeader(message: jakarta.jms.Message, headers: Set[JmsHeader]): Unit = + headers.foreach { + case JmsType(jmsType) => message.setJMSType(jmsType) + case JmsReplyTo(destination) => message.setJMSReplyTo(destination.create(jmsSession.session)) + case JmsCorrelationId(jmsCorrelationId) => message.setJMSCorrelationID(jmsCorrelationId) + case JmsExpiration(jmsExpiration) => message.setJMSExpiration(jmsExpiration) + case JmsDeliveryMode(_) | JmsPriority(_) | JmsTimeToLive(_) | JmsTimestamp(_) | JmsRedelivered(_) | + JmsMessageId(_) => // see #send(JmsMessage) + } +} + +/** + * Internal API. + */ +@InternalApi +private[impl] object JmsMessageProducer { + def apply(jmsSession: JmsProducerSession, settings: JmsProducerSettings, epoch: Int): JmsMessageProducer = { + val producer = jmsSession.session.createProducer(null) + if (settings.timeToLive.nonEmpty) { + producer.setTimeToLive(settings.timeToLive.get.toMillis) + } + new JmsMessageProducer(producer, jmsSession, epoch) + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageReader.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageReader.scala new file mode 100644 index 0000000000..8979abd6b8 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageReader.scala @@ -0,0 +1,90 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import jakarta.jms + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms._ +import akka.util.ByteString +import scala.annotation.tailrec +import scala.collection.JavaConverters._ + +@InternalApi +private[jakartajms] object JmsMessageReader { + + /** + * Read a [[akka.util.ByteString]] from a [[jakarta.jms.BytesMessage]] + */ + def readBytes(message: jms.BytesMessage, bufferSize: Int = 4096): ByteString = { + if (message.getBodyLength > Int.MaxValue) + sys.error(s"Message too large, unable to read ${message.getBodyLength} bytes of data") + + val buff = new Array[Byte](Math.min(message.getBodyLength, bufferSize).toInt) + + @tailrec def read(data: ByteString): ByteString = + if (message.getBodyLength == data.length) + data + else { + val len = message.readBytes(buff) + val d = buff.take(len) + read(data ++ ByteString(d)) + } + read(ByteString.empty) + } + + /** + * Read a byte array from a [[jakarta.jms.BytesMessage]] + */ + def readArray(message: jms.BytesMessage, bufferSize: Int = 4096): Array[Byte] = + readBytes(message, bufferSize).toArray + + private def createMap(keys: java.util.Enumeration[_], accessor: String => AnyRef) = + keys + .asInstanceOf[java.util.Enumeration[String]] + .asScala + .map { key => + key -> (accessor(key) match { + case v: java.lang.Boolean => v.booleanValue() + case v: java.lang.Byte => v.byteValue() + case v: java.lang.Short => v.shortValue() + case v: java.lang.Integer => v.intValue() + case v: java.lang.Long => v.longValue() + case v: java.lang.Float => v.floatValue() + case v: java.lang.Double => v.doubleValue() + case other => other + }) + } + .toMap + + /** + * Read a Scala Map from a [[jakarta.jms.MapMessage]] + */ + def readMap(message: jms.MapMessage): Map[String, Any] = + createMap(message.getMapNames, message.getObject) + + /** + * Extract a properties map from a [[jakarta.jms.Message]] + */ + def readProperties(message: jms.Message): Map[String, Any] = + createMap(message.getPropertyNames, message.getObjectProperty) + + /** + * Extract [[JmsHeader]]s from a [[jakarta.jms.Message]] + */ + def readHeaders(message: jms.Message): Set[JmsHeader] = { + def messageId = Option(message.getJMSMessageID).map(JmsMessageId(_)) + def timestamp = Some(JmsTimestamp(message.getJMSTimestamp)) + def correlationId = Option(message.getJMSCorrelationID).map(JmsCorrelationId(_)) + def replyTo = Option(message.getJMSReplyTo).map(Destination(_)).map(JmsReplyTo(_)) + def deliveryMode = Some(JmsDeliveryMode(message.getJMSDeliveryMode)) + def redelivered = Some(JmsRedelivered(message.getJMSRedelivered)) + def jmsType = Option(message.getJMSType).map(JmsType(_)) + def expiration = Some(JmsExpiration(message.getJMSExpiration)) + def priority = Some(JmsPriority(message.getJMSPriority)) + + Set(messageId, timestamp, correlationId, replyTo, deliveryMode, redelivered, jmsType, expiration, priority).flatten + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsProducerStage.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsProducerStage.scala new file mode 100644 index 0000000000..72b5a79a34 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsProducerStage.scala @@ -0,0 +1,279 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.{Done, NotUsed} +import akka.stream.ActorAttributes.SupervisionStrategy +import akka.stream._ +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms._ +import akka.stream.impl.Buffer +import akka.stream.scaladsl.Source +import akka.stream.stage._ +import akka.util.OptionVal +import jakarta.jms + +import scala.concurrent.Future +import scala.util.control.NoStackTrace +import scala.util.{Failure, Success, Try} + +/** + * Internal API. + */ +@InternalApi +private trait JmsProducerConnector extends JmsConnector[JmsProducerSession] { + this: TimerGraphStageLogic with StageLogging => + + protected final def createSession(connection: jms.Connection, + createDestination: jms.Session => jms.Destination): JmsProducerSession = { + val session = connection.createSession(false, AcknowledgeMode.AutoAcknowledge.mode) + new JmsProducerSession(connection, session, createDestination(session)) + } + + override val startConnection = false + + val status: JmsProducerMatValue = new JmsProducerMatValue { + override def connected: Source[InternalConnectionState, NotUsed] = + Source.future(connectionStateSource).flatMapConcat(identity) + } +} + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsProducerStage[E <: JmsEnvelope[PassThrough], PassThrough]( + settings: JmsProducerSettings, + destination: Destination +) extends GraphStageWithMaterializedValue[FlowShape[E, E], JmsProducerMatValue] { stage => + import JmsProducerStage._ + + private val in = Inlet[E]("JmsProducer.in") + private val out = Outlet[E]("JmsProducer.out") + + override def shape: FlowShape[E, E] = FlowShape.of(in, out) + + override protected def initialAttributes: Attributes = + super.initialAttributes and Attributes.name("JmsProducer") and ActorAttributes.IODispatcher + + override def createLogicAndMaterializedValue( + inheritedAttributes: Attributes + ): (GraphStageLogic, JmsProducerMatValue) = { + val logic: TimerGraphStageLogic with JmsProducerConnector = producerLogic(inheritedAttributes) + (logic, logic.status) + } + + private def producerLogic(inheritedAttributes: Attributes) = + new TimerGraphStageLogic(shape) with JmsProducerConnector with StageLogging { + + /* + * NOTE: the following code is heavily inspired by akka.stream.impl.fusing.MapAsync + * + * To get a condensed view of what the buffers and handler behavior is about, have a look there too. + */ + + private lazy val decider = inheritedAttributes.mandatoryAttribute[SupervisionStrategy].decider + + // the current connection epoch. Reconnects increment this epoch by 1. + private var currentJmsProducerEpoch = 0 + + // available producers for sending messages. Initially full, but might contain less elements if + // messages are currently in-flight. + private val jmsProducers: Buffer[JmsMessageProducer] = Buffer(settings.sessionCount, settings.sessionCount) + + // in-flight messages with the producers that were used to send them. + private val inFlightMessages: Buffer[Holder[E]] = + Buffer(settings.sessionCount, settings.sessionCount) + + protected val destination: Destination = stage.destination + protected val jmsSettings: JmsProducerSettings = settings + + override def preStart(): Unit = { + ec = executionContext(inheritedAttributes) + super.preStart() + initSessionAsync() + } + + override protected def onSessionOpened(jmsSession: JmsProducerSession): Unit = + sessionOpened(Try { + jmsProducers.enqueue(JmsMessageProducer(jmsSession, settings, currentJmsProducerEpoch)) + // startup situation: while producer pool was empty, the out port might have pulled. If so, pull from in port. + // Note that a message might be already in-flight; that's fine since this stage pre-fetches message from + // upstream anyway to increase throughput once the stream is started. + if (isAvailable(out)) pullIfNeeded() + }) + + override protected def connectionFailed(ex: Throwable): Unit = { + jmsProducers.clear() + currentJmsProducerEpoch += 1 + super.connectionFailed(ex) + } + + setHandler(out, new OutHandler { + override def onPull(): Unit = pushNextIfPossible() + + override def onDownstreamFinish(cause: Throwable): Unit = publishAndCompleteStage() + }) + + setHandler( + in, + new InHandler { + override def onUpstreamFinish(): Unit = if (inFlightMessages.isEmpty) publishAndCompleteStage() + + override def onUpstreamFailure(ex: Throwable): Unit = { + publishAndFailStage(ex) + } + + override def onPush(): Unit = { + val elem: E = grab(in) + elem match { + case _: JmsPassThrough[_] => + val holder = new Holder[E](NotYetThere) + inFlightMessages.enqueue(holder) + holder(Success(elem)) + pushNextIfPossible() + case m: JmsEnvelope[_] => + // create a holder object to capture the in-flight message, and enqueue it to preserve message order + val holder = new Holder[E](NotYetThere) + inFlightMessages.enqueue(holder) + sendWithRetries(SendAttempt(m.asInstanceOf[E], holder)) + case other => + log.warning("unhandled element []", other) + } + + // immediately ask for the next element if producers are available. + pullIfNeeded() + } + } + ) + + private def publishAndCompleteStage(): Unit = { + val previous = updateState(InternalConnectionState.JmsConnectorStopping(Success(Done))) + closeSessions() + closeConnectionAsync(JmsConnector.connection(previous)) + completeStage() + } + + override def onTimer(timerKey: Any): Unit = timerKey match { + case s: SendAttempt[E @unchecked] => sendWithRetries(s) + case _ => super.onTimer(timerKey) + } + + private def sendWithRetries(send: SendAttempt[E]): Unit = { + import send._ + if (jmsProducers.nonEmpty) { + val jmsProducer: JmsMessageProducer = jmsProducers.dequeue() + Future(jmsProducer.send(envelope)).andThen { + case tried => sendCompletedCB.invoke((send, tried, jmsProducer)) + } + } else { + nextTryOrFail(send, RetrySkippedOnMissingConnection) + } + } + + def nextTryOrFail(send: SendAttempt[E], ex: Throwable): Unit = { + import send._ + import settings.sendRetrySettings._ + if (maxRetries < 0 || attempt + 1 <= maxRetries) { + val nextAttempt = attempt + 1 + val delay = if (backoffMaxed) maxBackoff else waitTime(nextAttempt) + val backoffNowMaxed = backoffMaxed || delay == maxBackoff + scheduleOnce(send.copy(attempt = nextAttempt, backoffMaxed = backoffNowMaxed), delay) + } else { + holder(Failure(ex)) + handleFailure(ex, holder) + } + } + + private val sendCompletedCB = getAsyncCallback[(SendAttempt[E], Try[Unit], JmsMessageProducer)] { + case (send, outcome, jmsProducer) => + // same epoch indicates that the producer belongs to the current alive connection. + if (jmsProducer.epoch == currentJmsProducerEpoch) jmsProducers.enqueue(jmsProducer) + + import send._ + + outcome match { + case Success(_) => + holder(Success(send.envelope)) + pushNextIfPossible() + case Failure(t: jms.JMSException) => + nextTryOrFail(send, t) + case Failure(t) => + holder(Failure(t)) + handleFailure(t, holder) + } + } + + override def postStop(): Unit = finishStop() + + private def pullIfNeeded(): Unit = + if (jmsProducers.nonEmpty // only pull if a producer is available in the pool. + && !inFlightMessages.isFull // and a place is available in the in-flight queue. + && !hasBeenPulled(in)) + tryPull(in) + + private def pushNextIfPossible(): Unit = + if (inFlightMessages.isEmpty) { + // no messages in flight, are we about to complete? + if (isClosed(in)) publishAndCompleteStage() else pullIfNeeded() + } else if (inFlightMessages.peek().elem eq NotYetThere) { + // next message to be produced is still not there, we need to wait. + pullIfNeeded() + } else if (isAvailable(out)) { + val holder = inFlightMessages.dequeue() + holder.elem match { + case Success(elem) => + push(out, elem) + pullIfNeeded() // Ask for the next element. + + case Failure(ex) => handleFailure(ex, holder) + } + } + + private def handleFailure(ex: Throwable, holder: Holder[E]): Unit = + holder.supervisionDirectiveFor(decider, ex) match { + case Supervision.Stop => failStage(ex) // fail only if supervision asks for it. + case _ => pushNextIfPossible() + } + } +} + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] object JmsProducerStage { + + val NotYetThere = Failure(new Exception with NoStackTrace) + + /* + * NOTE: the following code is heavily inspired by akka.stream.impl.fusing.MapAsync + * + * To get a condensed view of what the Holder is about, have a look there too. + */ + class Holder[A](var elem: Try[A]) extends (Try[A] => Unit) { + + // To support both fail-fast when the supervision directive is Stop + // and not calling the decider multiple times, we need to cache the decider result and re-use that + private var cachedSupervisionDirective: OptionVal[Supervision.Directive] = OptionVal.None + + def supervisionDirectiveFor(decider: Supervision.Decider, ex: Throwable): Supervision.Directive = + cachedSupervisionDirective match { + case OptionVal.Some(d) => d + case OptionVal.None => + val d = decider(ex) + cachedSupervisionDirective = OptionVal.Some(d) + d + case other => throw new MatchError(other) + } + + override def apply(t: Try[A]): Unit = elem = t + } + + case class SendAttempt[E <: JmsEnvelope[_]](envelope: E, + holder: Holder[E], + attempt: Int = 0, + backoffMaxed: Boolean = false) +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsTxSourceStage.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsTxSourceStage.scala new file mode 100644 index 0000000000..dcf70ae5b0 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/JmsTxSourceStage.scala @@ -0,0 +1,81 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms.{AcknowledgeMode, Destination, JmsConsumerSettings, JmsTxAckTimeout, TxEnvelope} +import akka.stream.stage.{GraphStageLogic, GraphStageWithMaterializedValue} +import akka.stream.{Attributes, Outlet, SourceShape} +import jakarta.jms + +import scala.concurrent.{Await, TimeoutException} + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsTxSourceStage(settings: JmsConsumerSettings, destination: Destination) + extends GraphStageWithMaterializedValue[SourceShape[TxEnvelope], JmsConsumerMatValue] { + + private val out = Outlet[TxEnvelope]("JmsSource.out") + + override def shape: SourceShape[TxEnvelope] = SourceShape[TxEnvelope](out) + + override protected def initialAttributes: Attributes = Attributes.name("JmsTxConsumer") + + override def createLogicAndMaterializedValue( + inheritedAttributes: Attributes + ): (GraphStageLogic, JmsConsumerMatValue) = { + val logic = new JmsTxSourceStageLogic(inheritedAttributes) + (logic, logic.consumerControl) + } + + private final class JmsTxSourceStageLogic(inheritedAttributes: Attributes) + extends SourceStageLogic[TxEnvelope](shape, out, settings, destination, inheritedAttributes) { self => + protected def createSession(connection: jms.Connection, + createDestination: jms.Session => jakarta.jms.Destination) = { + val session = + connection.createSession(true, settings.acknowledgeMode.getOrElse(AcknowledgeMode.SessionTransacted).mode) + new JmsConsumerSession(connection, session, createDestination(session), self.destination) + } + + protected def pushMessage(msg: TxEnvelope): Unit = push(out, msg) + + override protected def onSessionOpened(consumerSession: JmsConsumerSession): Unit = + consumerSession + .createConsumer(settings.selector) + .map { consumer => + consumer.setMessageListener(new jms.MessageListener { + def onMessage(message: jms.Message): Unit = { + try { + val envelope = TxEnvelope(message, consumerSession) + handleMessage.invoke(envelope) + try { + // JMS spec defines that commit/rollback must be done on the same thread. + // While some JMS implementations work without this constraint, IBM MQ is + // very strict about the spec and throws exceptions when called from a different thread. + val action = Await.result(envelope.commitFuture, settings.ackTimeout) + action() + } catch { + case _: TimeoutException => + val exception = new JmsTxAckTimeout(settings.ackTimeout) + consumerSession.session.rollback() + if (settings.failStreamOnAckTimeout) { + handleError.invoke(exception) + } else { + log.warning(exception.getMessage) + } + } + } catch { + case e: IllegalArgumentException => handleError.invoke(e) // Invalid envelope, fail the stage + case e: jms.JMSException => handleError.invoke(e) + } + } + }) + } + .onComplete(sessionOpenedCB.invoke) + } + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/Sessions.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/Sessions.scala new file mode 100644 index 0000000000..81a4448309 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/Sessions.scala @@ -0,0 +1,129 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import java.util.concurrent.ArrayBlockingQueue + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms.{Destination, DurableTopic} +import akka.util.OptionVal +import jakarta.jms + +import scala.annotation.tailrec +import scala.concurrent.{ExecutionContext, Future} + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] sealed trait JmsSession { + + def connection: jms.Connection + + def session: jms.Session + + private[jakartajms] def abortSession(): Unit = closeSession() + + private[jakartajms] def closeSession(): Unit = session.close() +} + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsProducerSession(val connection: jms.Connection, + val session: jms.Session, + val jmsDestination: jms.Destination) + extends JmsSession + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] class JmsConsumerSession(val connection: jms.Connection, + val session: jms.Session, + val jmsDestination: jms.Destination, + val settingsDestination: Destination) + extends JmsSession { + + private[jakartajms] def createConsumer( + selector: Option[String] + )(implicit ec: ExecutionContext): Future[jms.MessageConsumer] = + Future { + (selector, settingsDestination) match { + case (None, t: DurableTopic) => + session.createDurableSubscriber(jmsDestination.asInstanceOf[jms.Topic], t.subscriberName) + + case (Some(expr), t: DurableTopic) => + session.createDurableSubscriber(jmsDestination.asInstanceOf[jms.Topic], t.subscriberName, expr, false) + + case (Some(expr), _) => + session.createConsumer(jmsDestination, expr) + + case (None, _) => + session.createConsumer(jmsDestination) + } + } +} + +case object SessionClosed + +/** + * Internal API. + */ +@InternalApi +private[jakartajms] final class JmsAckSession(override val connection: jms.Connection, + override val session: jms.Session, + override val jmsDestination: jms.Destination, + override val settingsDestination: Destination, + val maxPendingAcks: Int) + extends JmsConsumerSession(connection, session, jmsDestination, settingsDestination) { + + private val ackQueue = new ArrayBlockingQueue[Either[SessionClosed.type, () => Unit]](maxPendingAcks + 1) + private[jakartajms] var pendingAck = 0 + private var listenerRunning = true + + def isListenerRunning: Boolean = listenerRunning + + def maxPendingAcksReached: Boolean = pendingAck > maxPendingAcks + + def ack(message: jms.Message): Unit = ackQueue.put(Right(message.acknowledge _)) + + override def closeSession(): Unit = stopMessageListenerAndCloseSession() + + override def abortSession(): Unit = stopMessageListenerAndCloseSession() + + private def stopMessageListenerAndCloseSession(): Unit = { + try { + drainAcks() + } finally { + ackQueue.put(Left(SessionClosed)) + session.close() + } + } + + def ackBackpressure() = { + ackQueue.take() match { + case Left(SessionClosed) => + listenerRunning = false + case Right(action) => + action() + pendingAck -= 1 + } + } + + @tailrec + def drainAcks(): Unit = + OptionVal(ackQueue.poll()) match { + case OptionVal.Some(Left(SessionClosed)) => + listenerRunning = false + case OptionVal.Some(Right(action)) => + action() + pendingAck -= 1 + drainAcks() + case OptionVal.None => + case other => throw new MatchError(other) + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/SoftReferenceCache.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/SoftReferenceCache.scala new file mode 100644 index 0000000000..5e39118afd --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/SoftReferenceCache.scala @@ -0,0 +1,40 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.annotation.InternalApi + +import scala.collection.mutable +import scala.ref.SoftReference + +/** + * Internal API. + */ +@InternalApi +private final class SoftReferenceCache[K, V <: AnyRef] { + + private val cache = mutable.HashMap[K, SoftReference[V]]() + + def lookup(key: K, default: => V): V = + cache.get(key) match { + case Some(ref) => + ref.get match { + case Some(value) => value + case None => + purgeCache() // facing a garbage collected soft reference, purge other entries. + update(key, default) + } + + case None => update(key, default) + } + + private def update(key: K, value: V): V = { + cache.put(key, new SoftReference(value)) + value + } + + private def purgeCache(): Unit = + cache --= cache.collect { case (key, ref) if ref.get.isEmpty => key }.toVector +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/SourceStageLogic.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/SourceStageLogic.scala new file mode 100644 index 0000000000..b4252bcb08 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/impl/SourceStageLogic.scala @@ -0,0 +1,146 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import java.util.concurrent.atomic.AtomicBoolean + +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms.impl.InternalConnectionState.JmsConnectorStopping +import akka.stream.alpakka.jakartajms.{Destination, JmsConsumerSettings} +import akka.stream.scaladsl.Source +import akka.stream.stage.{OutHandler, StageLogging, TimerGraphStageLogic} +import akka.stream.{Attributes, Outlet, SourceShape} +import akka.{Done, NotUsed} + +import scala.collection.mutable +import scala.util.{Failure, Success} + +import jakarta.jms + +/** + * Internal API. + */ +@InternalApi +private trait JmsConsumerConnector extends JmsConnector[JmsConsumerSession] { + this: TimerGraphStageLogic with StageLogging => + + override val startConnection = true + + protected def createSession(connection: jms.Connection, + createDestination: jms.Session => jms.Destination): JmsConsumerSession + +} + +/** + * Internal API. + */ +@InternalApi +private abstract class SourceStageLogic[T](shape: SourceShape[T], + out: Outlet[T], + settings: JmsConsumerSettings, + val destination: Destination, + inheritedAttributes: Attributes) + extends TimerGraphStageLogic(shape) + with JmsConsumerConnector + with StageLogging { + + override protected def jmsSettings: JmsConsumerSettings = settings + private val queue = mutable.Queue[T]() + private val stopping = new AtomicBoolean(false) + private var stopped = false + + private val markStopped = getAsyncCallback[Done.type] { _ => + stopped = true + finishStop() + if (queue.isEmpty) completeStage() + } + + private val markAborted = getAsyncCallback[Throwable] { ex => + stopped = true + finishStop() + failStage(ex) + } + + protected val handleError = getAsyncCallback[Throwable] { e => + updateState(JmsConnectorStopping(Failure(e))) + failStage(e) + } + + override def preStart(): Unit = { + ec = executionContext(inheritedAttributes) + super.preStart() + initSessionAsync() + } + + private[jakartajms] val handleMessage = getAsyncCallback[T] { msg => + if (isAvailable(out)) { + if (queue.isEmpty) { + pushMessage(msg) + } else { + pushMessage(queue.dequeue()) + queue.enqueue(msg) + } + } else { + queue.enqueue(msg) + } + } + + protected def pushMessage(msg: T): Unit + + setHandler( + out, + new OutHandler { + override def onPull(): Unit = { + if (queue.nonEmpty) pushMessage(queue.dequeue()) + if (stopped && queue.isEmpty) completeStage() + } + + override def onDownstreamFinish(cause: Throwable): Unit = { + // no need to keep messages in the queue, downstream will never pull them. + queue.clear() + // keep processing async callbacks for stopSessions. + setKeepGoing(true) + stopSessions() + } + } + ) + + private def stopSessions(): Unit = + if (stopping.compareAndSet(false, true)) { + val status = updateState(JmsConnectorStopping(Success(Done))) + val connectionFuture = JmsConnector.connection(status) + + closeSessionsAsync().onComplete { _ => + closeConnectionAsync(connectionFuture).onComplete { _ => + // By this time, after stopping connection, closing sessions, all async message submissions to this + // stage should have been invoked. We invoke markStopped as the last item so it gets delivered after + // all JMS messages are delivered. This will allow the stage to complete after all pending messages + // are delivered, thus preventing message loss due to premature stage completion. + markStopped.invoke(Done) + } + } + } + + private def abortSessions(ex: Throwable): Unit = + if (stopping.compareAndSet(false, true)) { + if (log.isDebugEnabled) log.debug("aborting sessions ({})", ex.toString) + val status = updateState(JmsConnectorStopping(Failure(ex))) + val connectionFuture = JmsConnector.connection(status) + abortSessionsAsync().onComplete { _ => + closeConnectionAsync(connectionFuture).onComplete { _ => + markAborted.invoke(ex) + } + } + } + + def consumerControl: JmsConsumerMatValue = new JmsConsumerMatValue { + override def shutdown(): Unit = stopSessions() + override def abort(ex: Throwable): Unit = abortSessions(ex) + override def connected: Source[InternalConnectionState, NotUsed] = + Source.future(connectionStateSource).flatMapConcat(identity) + } + + override def postStop(): Unit = finishStop() +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConnectorState.java b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConnectorState.java new file mode 100644 index 0000000000..c6ded4568b --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConnectorState.java @@ -0,0 +1,11 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.javadsl; + +public enum JmsConnectorState { + + Disconnected, Connecting, Connected, Completing, Completed, Failing, Failed + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConsumer.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConsumer.scala new file mode 100644 index 0000000000..829430529f --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConsumer.scala @@ -0,0 +1,109 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.javadsl + +import jakarta.jms.Message +import akka.NotUsed +import akka.stream.alpakka.jakartajms._ +import akka.stream.javadsl.Source + +import scala.collection.JavaConverters._ + +/** + * Factory methods to create JMS consumers. + */ +object JmsConsumer { + + /** + * Creates a source emitting [[jakarta.jms.Message]] instances, and materializes a + * control instance to shut down the consumer. + */ + def create(settings: JmsConsumerSettings): akka.stream.javadsl.Source[Message, JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer.apply(settings).mapMaterializedValue(toConsumerControl).asJava + + /** + * Creates a source emitting Strings, and materializes a + * control instance to shut down the consumer. + */ + def textSource(settings: JmsConsumerSettings): akka.stream.javadsl.Source[String, JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer + .textSource(settings) + .mapMaterializedValue(toConsumerControl) + .asJava + + /** + * Creates a source emitting byte arrays, and materializes a + * control instance to shut down the consumer. + */ + def bytesSource(settings: JmsConsumerSettings): akka.stream.javadsl.Source[Array[Byte], JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer + .bytesSource(settings) + .mapMaterializedValue(toConsumerControl) + .asJava + + /** + * Creates a source emitting maps, and materializes a + * control instance to shut down the consumer. + */ + def mapSource( + settings: JmsConsumerSettings + ): akka.stream.javadsl.Source[java.util.Map[String, Any], JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer + .mapSource(settings) + .map(_.asJava) + .mapMaterializedValue(toConsumerControl) + .asJava + + /** + * Creates a source emitting de-serialized objects, and materializes a + * control instance to shut down the consumer. + */ + def objectSource( + settings: JmsConsumerSettings + ): akka.stream.javadsl.Source[java.io.Serializable, JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer + .objectSource(settings) + .mapMaterializedValue(toConsumerControl) + .asJava + + /** + * Creates a source emitting [[akka.stream.alpakka.jakartajms.AckEnvelope AckEnvelope]] instances, and materializes a + * control instance to shut down the consumer. + * It requires explicit acknowledgements on the envelopes. The acknowledgements must be called on the envelope and not on the message inside. + */ + def ackSource(settings: JmsConsumerSettings): akka.stream.javadsl.Source[AckEnvelope, JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer + .ackSource(settings) + .mapMaterializedValue(toConsumerControl) + .asJava + + /** + * Creates a source emitting [[akka.stream.alpakka.jakartajms.TxEnvelope TxEnvelope]] instances, and materializes a + * control instance to shut down the consumer. + * It requires explicit committing or rollback on the envelopes. + */ + def txSource(settings: JmsConsumerSettings): akka.stream.javadsl.Source[TxEnvelope, JmsConsumerControl] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer + .txSource(settings) + .mapMaterializedValue(toConsumerControl) + .asJava + + /** + * Creates a source browsing a JMS destination (which does not consume the messages) + * and emitting [[jakarta.jms.Message]] instances. + */ + def browse(settings: JmsBrowseSettings): akka.stream.javadsl.Source[Message, NotUsed] = + akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer.browse(settings).asJava + + private def toConsumerControl(scalaControl: scaladsl.JmsConsumerControl) = new JmsConsumerControl { + + override def connectorState(): Source[JmsConnectorState, NotUsed] = + scalaControl.connectorState.map(_.asJava).asJava + + override def shutdown(): Unit = scalaControl.shutdown() + + override def abort(ex: Throwable): Unit = scalaControl.abort(ex) + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConsumerControl.java b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConsumerControl.java new file mode 100644 index 0000000000..7d5ee9c076 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsConsumerControl.java @@ -0,0 +1,18 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.javadsl; + +import akka.NotUsed; +import akka.stream.KillSwitch; +import akka.stream.javadsl.Source; + +public interface JmsConsumerControl extends KillSwitch { + + /** + * source that provides connector status change information. + * Only the most recent connector state is buffered if the source is not consumed. + */ + Source connectorState(); +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsProducer.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsProducer.scala new file mode 100644 index 0000000000..507e6dea28 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsProducer.scala @@ -0,0 +1,115 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.javadsl + +import java.util.concurrent.CompletionStage + +import akka.stream.alpakka.jakartajms.{scaladsl, JmsEnvelope, JmsMessage, JmsProducerSettings} +import akka.stream.javadsl.Source +import akka.stream.scaladsl.{Flow, Keep} +import akka.util.ByteString +import akka.{Done, NotUsed} + +import scala.collection.JavaConverters._ +import scala.compat.java8.FutureConverters + +/** + * Factory methods to create JMS producers. + */ +object JmsProducer { + + /** + * Create a flow to send [[akka.stream.alpakka.jakartajms.JmsMessage JmsMessage]] sub-classes to + * a JMS broker. + */ + def flow[R <: JmsMessage]( + settings: JmsProducerSettings + ): akka.stream.javadsl.Flow[R, R, JmsProducerStatus] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer.flow(settings).mapMaterializedValue(toProducerStatus).asJava + + /** + * Create a flow to send [[akka.stream.alpakka.jakartajms.JmsEnvelope JmsEnvelope]] sub-classes to + * a JMS broker to support pass-through of data. + */ + def flexiFlow[PassThrough]( + settings: JmsProducerSettings + ): akka.stream.javadsl.Flow[JmsEnvelope[PassThrough], JmsEnvelope[PassThrough], JmsProducerStatus] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .flexiFlow[PassThrough](settings) + .mapMaterializedValue(toProducerStatus) + .asJava + + /** + * Create a sink to send [[akka.stream.alpakka.jakartajms.JmsMessage JmsMessage]] sub-classes to + * a JMS broker. + */ + def sink[R <: JmsMessage]( + settings: JmsProducerSettings + ): akka.stream.javadsl.Sink[R, CompletionStage[Done]] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .sink(settings) + .mapMaterializedValue(FutureConverters.toJava) + .asJava + + /** + * Create a sink to send Strings as text messages to a JMS broker. + */ + def textSink(settings: JmsProducerSettings): akka.stream.javadsl.Sink[String, CompletionStage[Done]] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .textSink(settings) + .mapMaterializedValue(FutureConverters.toJava) + .asJava + + /** + * Create a sink to send byte arrays to a JMS broker. + */ + def bytesSink(settings: JmsProducerSettings): akka.stream.javadsl.Sink[Array[Byte], CompletionStage[Done]] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .bytesSink(settings) + .mapMaterializedValue(FutureConverters.toJava) + .asJava + + /** + * Create a sink to send [[akka.util.ByteString ByteString]]s to a JMS broker. + */ + def byteStringSink(settings: JmsProducerSettings): akka.stream.javadsl.Sink[ByteString, CompletionStage[Done]] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .byteStringSink(settings) + .mapMaterializedValue(FutureConverters.toJava) + .asJava + + /** + * Create a sink to send map structures to a JMS broker. + */ + def mapSink( + settings: JmsProducerSettings + ): akka.stream.javadsl.Sink[java.util.Map[String, Any], CompletionStage[Done]] = { + + val scalaSink = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .mapSink(settings) + .mapMaterializedValue(FutureConverters.toJava) + val javaToScalaConversion = + Flow.fromFunction((javaMap: java.util.Map[String, Any]) => javaMap.asScala.toMap) + javaToScalaConversion.toMat(scalaSink)(Keep.right).asJava + } + + /** + * Create a sink to send serialized objects to a JMS broker. + */ + def objectSink( + settings: JmsProducerSettings + ): akka.stream.javadsl.Sink[java.io.Serializable, CompletionStage[Done]] = + akka.stream.alpakka.jakartajms.scaladsl.JmsProducer + .objectSink(settings) + .mapMaterializedValue(FutureConverters.toJava) + .asJava + + private def toProducerStatus(scalaStatus: scaladsl.JmsProducerStatus) = new JmsProducerStatus { + + override def connectorState: Source[JmsConnectorState, NotUsed] = + scalaStatus.connectorState.map(_.asJava).asJava + } +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsProducerStatus.java b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsProducerStatus.java new file mode 100644 index 0000000000..9bba6e4169 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/javadsl/JmsProducerStatus.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.javadsl; + +import akka.NotUsed; +import akka.stream.javadsl.Source; + +public interface JmsProducerStatus { + + /** + * source that provides connector status change information. + * Only the most recent connector state is buffered if the source is not consumed. + */ + Source connectorState(); +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsConnectorState.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsConnectorState.scala new file mode 100644 index 0000000000..fdc4af431b --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsConnectorState.scala @@ -0,0 +1,56 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.scaladsl + +import akka.NotUsed +import akka.stream.KillSwitch +import akka.stream.scaladsl.Source +import akka.stream.alpakka.jakartajms.javadsl +import akka.stream.alpakka.jakartajms.scaladsl.JmsConnectorState._ + +trait JmsProducerStatus { + + /** + * source that provides connector status change information. + * Only the most recent connector state is buffered if the source is not consumed. + */ + def connectorState: Source[JmsConnectorState, NotUsed] + +} + +/** + * Handle to shut down consumers and to inspect the connectivity to the JMS broker. + */ +trait JmsConsumerControl extends KillSwitch { + + /** + * source that provides connector status change information. + * Only the most recent connector state is buffered if the source is not consumed. + */ + def connectorState: Source[JmsConnectorState, NotUsed] + +} + +sealed trait JmsConnectorState { + final def asJava: javadsl.JmsConnectorState = this match { + case Disconnected => javadsl.JmsConnectorState.Disconnected + case Connecting(_) => javadsl.JmsConnectorState.Connecting + case Connected => javadsl.JmsConnectorState.Connected + case Completing => javadsl.JmsConnectorState.Completing + case Completed => javadsl.JmsConnectorState.Completed + case Failing(_) => javadsl.JmsConnectorState.Failing + case Failed(_) => javadsl.JmsConnectorState.Failed + } +} + +object JmsConnectorState { + case object Disconnected extends JmsConnectorState + case class Connecting(attempt: Int) extends JmsConnectorState + case object Connected extends JmsConnectorState + case object Completing extends JmsConnectorState + case object Completed extends JmsConnectorState + case class Failing(exception: Throwable) extends JmsConnectorState + case class Failed(exception: Throwable) extends JmsConnectorState +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsConsumer.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsConsumer.scala new file mode 100644 index 0000000000..3ea8d7a99c --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsConsumer.scala @@ -0,0 +1,123 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.scaladsl + +import akka.NotUsed +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.impl._ +import akka.stream.scaladsl.Source +import jakarta.jms + +import scala.collection.JavaConverters._ + +/** + * Factory methods to create JMS consumers. + */ +object JmsConsumer { + + /** + * Creates a source emitting [[jakarta.jms.Message]] instances, and materializes a + * control instance to shut down the consumer. + */ + def apply(settings: JmsConsumerSettings): Source[jakarta.jms.Message, JmsConsumerControl] = + settings.destination match { + case None => throw new IllegalArgumentException(noConsumerDestination(settings)) + case Some(destination) => + Source.fromGraph(new JmsConsumerStage(settings, destination)).mapMaterializedValue(toConsumerControl) + } + + /** + * Creates a source emitting Strings, and materializes a + * control instance to shut down the consumer. + */ + def textSource(settings: JmsConsumerSettings): Source[String, JmsConsumerControl] = + apply(settings).map(msg => msg.asInstanceOf[jms.TextMessage].getText) + + /** + * Creates a source emitting maps, and materializes a + * control instance to shut down the consumer. + */ + def mapSource(settings: JmsConsumerSettings): Source[Map[String, Any], JmsConsumerControl] = + apply(settings).map { msg => + val mapMessage = msg.asInstanceOf[jms.MapMessage] + + mapMessage.getMapNames.asScala.foldLeft(Map[String, Any]()) { (result, key) => + val keyAsString = key.toString + val value = mapMessage.getObject(keyAsString) + result.+(keyAsString -> value) + } + } + + /** + * Creates a source emitting byte arrays, and materializes a + * control instance to shut down the consumer. + */ + def bytesSource(settings: JmsConsumerSettings): Source[Array[Byte], JmsConsumerControl] = + apply(settings).map { msg => + val byteMessage = msg.asInstanceOf[jms.BytesMessage] + val byteArray = new Array[Byte](byteMessage.getBodyLength.toInt) + byteMessage.readBytes(byteArray) + byteArray + } + + /** + * Creates a source emitting de-serialized objects, and materializes a + * control instance to shut down the consumer. + */ + def objectSource(settings: JmsConsumerSettings): Source[java.io.Serializable, JmsConsumerControl] = + apply(settings).map(msg => msg.asInstanceOf[jms.ObjectMessage].getObject) + + /** + * Creates a source emitting [[akka.stream.alpakka.jakartajms.AckEnvelope AckEnvelope]] instances, and materializes a + * control instance to shut down the consumer. + * It requires explicit acknowledgements on the envelopes. The acknowledgements must be called on the envelope and not on the message inside. + */ + def ackSource(settings: JmsConsumerSettings): Source[AckEnvelope, JmsConsumerControl] = settings.destination match { + case None => throw new IllegalArgumentException(noConsumerDestination(settings)) + case Some(destination) => + Source.fromGraph(new JmsAckSourceStage(settings, destination)).mapMaterializedValue(toConsumerControl) + } + + /** + * Creates a source emitting [[akka.stream.alpakka.jakartajms.TxEnvelope TxEnvelope]] instances, and materializes a + * control instance to shut down the consumer. + * It requires explicit committing or rollback on the envelopes. + */ + def txSource(settings: JmsConsumerSettings): Source[TxEnvelope, JmsConsumerControl] = settings.destination match { + case None => throw new IllegalArgumentException(noConsumerDestination(settings)) + case Some(destination) => + Source.fromGraph(new JmsTxSourceStage(settings, destination)).mapMaterializedValue(toConsumerControl) + } + + /** + * Creates a source browsing a JMS destination (which does not consume the messages) + * and emitting [[jakarta.jms.Message]] instances. + * Completes: when all messages have been read + */ + def browse(settings: JmsBrowseSettings): Source[jakarta.jms.Message, NotUsed] = settings.destination match { + case None => throw new IllegalArgumentException(noBrowseDestination(settings)) + case Some(destination) => Source.fromGraph(new JmsBrowseStage(settings, destination)) + } + + private def noConsumerDestination(settings: JmsConsumerSettings) = + s"""Unable to create JmsConsumer: its needs a destination to read messages from, but none was provided in + |$settings + |Please use withQueue, withTopic or withDestination to specify a destination.""".stripMargin + + private def noBrowseDestination(settings: JmsBrowseSettings) = + s"""Unable to create JmsConsumer browser: its needs a destination to read messages from, but none was provided in + |$settings + |Please use withQueue or withDestination to specify a destination.""".stripMargin + + private def toConsumerControl(internal: JmsConsumerMatValue) = new JmsConsumerControl { + + override def shutdown(): Unit = internal.shutdown() + + override def abort(ex: Throwable): Unit = internal.abort(ex) + + override def connectorState: Source[JmsConnectorState, NotUsed] = transformConnectorState(internal.connected) + } + +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsProducer.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsProducer.scala new file mode 100644 index 0000000000..d6f5fa81f5 --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsProducer.scala @@ -0,0 +1,96 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.scaladsl + +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.impl.{JmsProducerMatValue, JmsProducerStage} +import akka.stream.scaladsl.{Flow, Keep, Sink, Source} +import akka.util.ByteString +import akka.{Done, NotUsed} + +import scala.concurrent.Future + +/** + * Factory methods to create JMS producers. + */ +object JmsProducer { + + /** + * Create a flow to send [[akka.stream.alpakka.jakartajms.JmsMessage JmsMessage]] sub-classes to + * a JMS broker. + */ + def flow[T <: JmsMessage](settings: JmsProducerSettings): Flow[T, T, JmsProducerStatus] = + settings.destination match { + case None => throw new IllegalArgumentException(noProducerDestination(settings)) + case Some(destination) => + Flow[T] + .viaMat(Flow.fromGraph(new JmsProducerStage[T, NotUsed](settings, destination)))(Keep.right) + .mapMaterializedValue(toProducerStatus) + } + + /** + * Create a flow to send [[akka.stream.alpakka.jakartajms.JmsEnvelope JmsEnvelope]] sub-classes to + * a JMS broker to support pass-through of data. + */ + def flexiFlow[PassThrough]( + settings: JmsProducerSettings + ): Flow[JmsEnvelope[PassThrough], JmsEnvelope[PassThrough], JmsProducerStatus] = settings.destination match { + case None => throw new IllegalArgumentException(noProducerDestination(settings)) + case Some(destination) => + Flow + .fromGraph(new JmsProducerStage[JmsEnvelope[PassThrough], PassThrough](settings, destination)) + .mapMaterializedValue(toProducerStatus) + } + + /** + * Create a sink to send [[akka.stream.alpakka.jakartajms.JmsMessage JmsMessage]] sub-classes to + * a JMS broker. + */ + def sink(settings: JmsProducerSettings): Sink[JmsMessage, Future[Done]] = + flow(settings).toMat(Sink.ignore)(Keep.right) + + /** + * Create a sink to send Strings as text messages to a JMS broker. + */ + def textSink(settings: JmsProducerSettings): Sink[String, Future[Done]] = + Flow.fromFunction((s: String) => JmsTextMessage(s)).via(flow(settings)).toMat(Sink.ignore)(Keep.right) + + /** + * Create a sink to send byte arrays to a JMS broker. + */ + def bytesSink(settings: JmsProducerSettings): Sink[Array[Byte], Future[Done]] = + Flow.fromFunction((s: Array[Byte]) => JmsByteMessage(s)).via(flow(settings)).toMat(Sink.ignore)(Keep.right) + + /** + * Create a sink to send [[akka.util.ByteString ByteString]]s to a JMS broker. + */ + def byteStringSink(settings: JmsProducerSettings): Sink[ByteString, Future[Done]] = + Flow.fromFunction((s: ByteString) => JmsByteStringMessage(s)).via(flow(settings)).toMat(Sink.ignore)(Keep.right) + + /** + * Create a sink to send map structures to a JMS broker. + */ + def mapSink(settings: JmsProducerSettings): Sink[Map[String, Any], Future[Done]] = + Flow.fromFunction((s: Map[String, Any]) => JmsMapMessage(s)).via(flow(settings)).toMat(Sink.ignore)(Keep.right) + + /** + * Create a sink to send serialized objects to a JMS broker. + */ + def objectSink(settings: JmsProducerSettings): Sink[java.io.Serializable, Future[Done]] = + Flow + .fromFunction((s: java.io.Serializable) => JmsObjectMessage(s)) + .via(flow(settings)) + .toMat(Sink.ignore)(Keep.right) + + private def toProducerStatus(internal: JmsProducerMatValue) = new JmsProducerStatus { + + override def connectorState: Source[JmsConnectorState, NotUsed] = transformConnectorState(internal.connected) + } + + private def noProducerDestination(settings: JmsProducerSettings) = + s"""Unable to create JmsProducer: it needs a default destination to send messages to, but none was provided in + |$settings + |Please use withQueue, withTopic or withDestination to specify a destination.""".stripMargin +} diff --git a/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/package.scala b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/package.scala new file mode 100644 index 0000000000..433b89a71e --- /dev/null +++ b/jakarta-jms/src/main/scala/akka/stream/alpakka/jakartajms/scaladsl/package.scala @@ -0,0 +1,27 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms +import akka.{Done, NotUsed} +import akka.annotation.InternalApi +import akka.stream.alpakka.jakartajms.impl.InternalConnectionState +import akka.stream.scaladsl.Source + +import scala.util.{Failure, Success} + +package object scaladsl { + @InternalApi private[scaladsl] def transformConnectorState(source: Source[InternalConnectionState, NotUsed]) = { + import InternalConnectionState._ + source.map { + case JmsConnectorDisconnected => JmsConnectorState.Disconnected + case _: JmsConnectorConnected => JmsConnectorState.Connected + case i: JmsConnectorInitializing => JmsConnectorState.Connecting(i.attempt + 1) + case JmsConnectorStopping(Success(Done)) => JmsConnectorState.Completing + case JmsConnectorStopping(Failure(t)) => JmsConnectorState.Failing(t) + case JmsConnectorStopped(Success(Done)) => JmsConnectorState.Completed + case JmsConnectorStopped(Failure(t)) => JmsConnectorState.Failed(t) + case other => throw new MatchError(other) + } + } +} diff --git a/jakarta-jms/src/test/java/akka/stream/alpakka/jakartajms/javadsl/JmsAckConnectorsTest.java b/jakarta-jms/src/test/java/akka/stream/alpakka/jakartajms/javadsl/JmsAckConnectorsTest.java new file mode 100644 index 0000000000..cb0d6e9639 --- /dev/null +++ b/jakarta-jms/src/test/java/akka/stream/alpakka/jakartajms/javadsl/JmsAckConnectorsTest.java @@ -0,0 +1,377 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.javadsl; + +import akka.Done; +import akka.actor.ActorSystem; +import akka.japi.Pair; +import akka.stream.alpakka.jakartajms.*; +import akka.stream.alpakka.testkit.javadsl.LogCapturingJunit4; +import akka.stream.javadsl.Sink; +import akka.stream.javadsl.Source; +import akka.testkit.javadsl.TestKit; +import com.typesafe.config.Config; +import org.apache.activemq.artemis.jms.client.ActiveMQQueue; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import jakartajmstestkit.JmsBroker; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.TextMessage; +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; + +public class JmsAckConnectorsTest { + + @Rule public final LogCapturingJunit4 logCapturing = new LogCapturingJunit4(); + + private static ActorSystem system; + private static Config consumerConfig; + private static Config producerConfig; + + @BeforeClass + public static void setup() { + system = ActorSystem.create(); + consumerConfig = system.settings().config().getConfig(JmsConsumerSettings.configPath()); + producerConfig = system.settings().config().getConfig(JmsProducerSettings.configPath()); + } + + @AfterClass + public static void teardown() { + TestKit.shutdownActorSystem(system); + } + + private List createTestMessageList() { + List intsIn = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + List msgsIn = new ArrayList<>(); + for (Integer n : intsIn) { + final Map map = new HashMap<>(); + map.put("Number", n); + map.put("IsOdd", n % 2 == 1); + map.put("IsEven", n % 2 == 0); + + msgsIn.add(JmsTextMessage.create(n.toString()).withProperties(map)); + } + + return msgsIn; + } + + @Test + public void publishAndConsume() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + Source.from(in).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(in.size()) + .map(env -> new Pair<>(env, ((TextMessage) env.message()).getText())) + .map( + pair -> { + pair.first().acknowledge(); + return pair.second(); + }) + .runWith(Sink.seq(), system); + List out = new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + Collections.sort(out); + assertEquals(in, out); + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithProperties() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(msgsIn.size()) + .map( + env -> { + env.acknowledge(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithHeaders() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = + createTestMessageList().stream() + .map(jmsTextMessage -> jmsTextMessage.withHeader(JmsType.create("type"))) + .map( + jmsTextMessage -> + jmsTextMessage.withHeader(JmsCorrelationId.create("correlationId"))) + .map(jmsTextMessage -> jmsTextMessage.withHeader(JmsReplyTo.queue("test-reply"))) + .collect(Collectors.toList()); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(msgsIn.size()) + .map( + env -> { + env.acknowledge(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(outMsg.getJMSType(), "type"); + assertEquals(outMsg.getJMSCorrelationID(), "correlationId"); + assertEquals(((ActiveMQQueue) outMsg.getJMSReplyTo()).getQueueName(), "test-reply"); + msgIdx++; + } + }); + } + + @Test + public void publishJmsTextMessagesWithPropertiesAndConsumeThemWithASelector() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withQueue("test") + .withSelector("IsOdd = TRUE")); + + List oddMsgsIn = + msgsIn.stream() + .filter(msg -> Integer.parseInt(msg.body()) % 2 == 1) + .collect(Collectors.toList()); + assertEquals(5, oddMsgsIn.size()); + + CompletionStage> result = + jmsSource + .take(oddMsgsIn.size()) + .map( + env -> { + env.acknowledge(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + oddMsgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + oddMsgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (oddMsgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(1, outMsg.getIntProperty("Number") % 2); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeTopic() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + List inNumbers = + IntStream.range(0, 10).boxed().map(String::valueOf).collect(Collectors.toList()); + + Sink> jmsTopicSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + Sink> jmsTopicSink2 = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + + Source jmsTopicSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(1) + .withBufferSize(0) + .withTopic("topic")); + Source jmsTopicSource2 = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(1) + .withBufferSize(0) + .withTopic("topic")); + + CompletionStage> result = + jmsTopicSource + .take(in.size() + inNumbers.size()) + .map( + env -> { + env.acknowledge(); + return ((TextMessage) env.message()).getText(); + }) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + CompletionStage> result2 = + jmsTopicSource2 + .take(in.size() + inNumbers.size()) + .map( + env -> { + env.acknowledge(); + return ((TextMessage) env.message()).getText(); + }) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + + Thread.sleep(500); + + Source.from(in).runWith(jmsTopicSink, system); + Source.from(inNumbers).runWith(jmsTopicSink2, system); + + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result.toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result2.toCompletableFuture().get(5, TimeUnit.SECONDS)); + }); + } + + private void withServer(ConsumerChecked test) throws Exception { + JmsBroker broker = JmsBroker.apply(); + try { + test.accept(broker); + Thread.sleep(500); + } finally { + broker.stop(); + } + } + + @FunctionalInterface + private interface ConsumerChecked { + void accept(T elt) throws Exception; + } +} diff --git a/jakarta-jms/src/test/java/docs/javadsl/JmsBufferedAckConnectorsTest.java b/jakarta-jms/src/test/java/docs/javadsl/JmsBufferedAckConnectorsTest.java new file mode 100644 index 0000000000..759757deb8 --- /dev/null +++ b/jakarta-jms/src/test/java/docs/javadsl/JmsBufferedAckConnectorsTest.java @@ -0,0 +1,383 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.javadsl; + +import akka.Done; +import akka.actor.ActorSystem; +import akka.japi.Pair; +import akka.stream.alpakka.jakartajms.*; +import akka.stream.alpakka.jakartajms.javadsl.JmsConsumer; +import akka.stream.alpakka.jakartajms.javadsl.JmsConsumerControl; +import akka.stream.alpakka.jakartajms.javadsl.JmsProducer; +import akka.stream.alpakka.testkit.javadsl.LogCapturingJunit4; +import akka.stream.javadsl.Sink; +import akka.stream.javadsl.Source; +import akka.testkit.javadsl.TestKit; +import jakartajmstestkit.JmsBroker; +import com.typesafe.config.Config; +import org.apache.activemq.artemis.jms.client.ActiveMQQueue; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.TextMessage; +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; + +public class JmsBufferedAckConnectorsTest { + + @Rule public final LogCapturingJunit4 logCapturing = new LogCapturingJunit4(); + + private static ActorSystem system; + private static Config consumerConfig; + private static Config producerConfig; + + @BeforeClass + public static void setup() { + system = ActorSystem.create(); + consumerConfig = system.settings().config().getConfig(JmsConsumerSettings.configPath()); + producerConfig = system.settings().config().getConfig(JmsProducerSettings.configPath()); + } + + @AfterClass + public static void teardown() { + TestKit.shutdownActorSystem(system); + } + + private List createTestMessageList() { + List intsIn = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + List msgsIn = new ArrayList<>(); + for (Integer n : intsIn) { + msgsIn.add( + JmsTextMessage.create(n.toString()) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0)); + } + + return msgsIn; + } + + @Test + public void publishAndConsume() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + Source.from(in).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(5) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(in.size()) + .map(env -> new Pair<>(env, ((TextMessage) env.message()).getText())) + .map( + pair -> { + pair.first().acknowledge(); + return pair.second(); + }) + .runWith(Sink.seq(), system); + List out = new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + Collections.sort(out); + assertEquals(in, out); + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithProperties() throws Exception { + withServer( + server -> { + // #source + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #source + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + + // #source + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(system, connectionFactory) + .withSessionCount(5) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(msgsIn.size()) + .map( + envelope -> { + envelope.acknowledge(); + return envelope.message(); + }) + .runWith(Sink.seq(), system); + // #source + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithHeaders() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = + createTestMessageList().stream() + .map(jmsTextMessage -> jmsTextMessage.withHeader(JmsType.create("type"))) + .map( + jmsTextMessage -> + jmsTextMessage.withHeader(JmsCorrelationId.create("correlationId"))) + .map(jmsTextMessage -> jmsTextMessage.withHeader(JmsReplyTo.queue("test-reply"))) + .collect(Collectors.toList()); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(msgsIn.size()) + .map( + env -> { + env.acknowledge(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(outMsg.getJMSType(), "type"); + assertEquals(outMsg.getJMSCorrelationID(), "correlationId"); + assertEquals(((ActiveMQQueue) outMsg.getJMSReplyTo()).getQueueName(), "test-reply"); + msgIdx++; + } + }); + } + + @Test + public void publishJmsTextMessagesWithPropertiesAndConsumeThemWithASelector() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(5) + .withQueue("test") + .withSelector("IsOdd = TRUE")); + + List oddMsgsIn = + msgsIn.stream() + .filter(msg -> Integer.valueOf(msg.body()) % 2 == 1) + .collect(Collectors.toList()); + assertEquals(5, oddMsgsIn.size()); + + CompletionStage> result = + jmsSource + .take(oddMsgsIn.size()) + .map( + env -> { + env.acknowledge(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + oddMsgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + oddMsgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (oddMsgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(1, outMsg.getIntProperty("Number") % 2); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeTopic() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + List inNumbers = + IntStream.range(0, 10).boxed().map(String::valueOf).collect(Collectors.toList()); + + Sink> jmsTopicSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + Sink> jmsTopicSink2 = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + + Source jmsTopicSource = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(1) + .withBufferSize(5) + .withTopic("topic")); + Source jmsTopicSource2 = + JmsConsumer.ackSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(1) + .withBufferSize(5) + .withTopic("topic")); + + CompletionStage> result = + jmsTopicSource + .take(in.size() + inNumbers.size()) + .map( + env -> { + env.acknowledge(); + return ((TextMessage) env.message()).getText(); + }) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + CompletionStage> result2 = + jmsTopicSource2 + .take(in.size() + inNumbers.size()) + .map( + env -> { + env.acknowledge(); + return ((TextMessage) env.message()).getText(); + }) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + + Thread.sleep(500); + + Source.from(in).runWith(jmsTopicSink, system); + Source.from(inNumbers).runWith(jmsTopicSink2, system); + + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result.toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result2.toCompletableFuture().get(5, TimeUnit.SECONDS)); + }); + } + + private void withServer(ConsumerChecked test) throws Exception { + JmsBroker broker = JmsBroker.apply(); + try { + test.accept(broker); + Thread.sleep(500); + } finally { + if (broker.isStarted()) { + broker.stop(); + } + } + } + + @FunctionalInterface + private interface ConsumerChecked { + void accept(T elt) throws Exception; + } +} diff --git a/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java b/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java new file mode 100644 index 0000000000..ac82ed3f9e --- /dev/null +++ b/jakarta-jms/src/test/java/docs/javadsl/JmsConnectorsTest.java @@ -0,0 +1,954 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.javadsl; + +import akka.Done; +import akka.NotUsed; +import akka.actor.ActorSystem; +import akka.japi.Pair; +import akka.stream.alpakka.jakartajms.Destination; +import akka.stream.alpakka.jakartajms.*; +import akka.stream.alpakka.jakartajms.javadsl.JmsConsumer; +import akka.stream.alpakka.jakartajms.javadsl.JmsConsumerControl; +import akka.stream.alpakka.jakartajms.javadsl.JmsProducer; +import akka.stream.alpakka.jakartajms.javadsl.JmsProducerStatus; +import akka.stream.alpakka.testkit.javadsl.LogCapturingJunit4; +import akka.stream.javadsl.Flow; +import akka.stream.javadsl.Keep; +import akka.stream.javadsl.Sink; +import akka.stream.javadsl.Source; +import akka.testkit.javadsl.TestKit; +import com.typesafe.config.Config; +import jakartajmstestkit.JmsBroker; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.apache.activemq.artemis.jms.client.ActiveMQSession; +import org.apache.activemq.artemis.jms.client.ActiveMQQueue; +import org.apache.activemq.artemis.jms.client.ActiveMQTextMessage; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import scala.util.Failure; +import scala.util.Success; +import scala.util.Try; + +import jakarta.jms.*; +import java.nio.charset.Charset; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static junit.framework.TestCase.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +final class DummyJavaTests implements java.io.Serializable { + + private static final long serialVersionUID = 1234567L; + + private final String value; + + DummyJavaTests(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (o instanceof DummyJavaTests) { + return ((DummyJavaTests) o).value.equals(this.value); + } + return false; + } + + @Override + public int hashCode() { + return value != null ? value.hashCode() : 0; + } +} + +public class JmsConnectorsTest { + + @Rule public final LogCapturingJunit4 logCapturing = new LogCapturingJunit4(); + + private static ActorSystem system; + private static Config producerConfig; + private static Config browseConfig; + + @BeforeClass + public static void setup() { + system = ActorSystem.create(); + producerConfig = system.settings().config().getConfig(JmsProducerSettings.configPath()); + browseConfig = system.settings().config().getConfig(JmsBrowseSettings.configPath()); + } + + @AfterClass + public static void teardown() { + TestKit.shutdownActorSystem(system); + } + + private List createTestMessageList() { + List intsIn = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + List msgsIn = new ArrayList<>(); + for (Integer n : intsIn) { + + // #create-messages-with-properties + JmsTextMessage message = + akka.stream.alpakka.jakartajms.JmsTextMessage.create(n.toString()) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0); + // #create-messages-with-properties + + msgsIn.add(message); + } + + return msgsIn; + } + + @Test + public void publishAndConsumeJmsTextMessage() throws Exception { + withServer( + server -> { + // #connection-factory + // #text-sink + // #text-source + jakarta.jms.ConnectionFactory connectionFactory = server.createConnectionFactory(); + // #text-source + // #connection-factory + // #text-sink + + // #text-sink + + Sink> jmsSink = + JmsProducer.textSink( + JmsProducerSettings.create(system, connectionFactory).withQueue("test")); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + CompletionStage finished = Source.from(in).runWith(jmsSink, system); + // #text-sink + + // #text-source + Source jmsSource = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage> result = + jmsSource.take(in.size()).runWith(Sink.seq(), system); + // #text-source + + assertEquals(Done.getInstance(), finished.toCompletableFuture().get(3, TimeUnit.SECONDS)); + assertEquals(in, result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + }); + } + + @Test + public void publishAndConsumeJmsObjectMessage() throws Exception { + withServer( + server -> { + // #connection-factory-object + // #object-sink + // #object-source + ActiveMQConnectionFactory connectionFactory = + (ActiveMQConnectionFactory) server.createConnectionFactory(); + connectionFactory.setDeserializationWhiteList( + DummyJavaTests.class.getPackage().getName()); + + // #object-source + // #connection-factory-object + // #object-sink + + // #object-sink + Sink> jmsSink = + JmsProducer.objectSink( + JmsProducerSettings.create(system, connectionFactory).withQueue("test")); + + java.io.Serializable in = new DummyJavaTests("javaTest"); + CompletionStage finished = Source.single(in).runWith(jmsSink, system); + // #object-sink + + // #object-source + Source jmsSource = + JmsConsumer.objectSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage result = + jmsSource.take(1).runWith(Sink.head(), system); + // #object-source + + assertEquals(Done.getInstance(), finished.toCompletableFuture().get(3, TimeUnit.SECONDS)); + Object resultObject = result.toCompletableFuture().get(3, TimeUnit.SECONDS); + assertEquals(resultObject, in); + }); + } + + @Test + public void publishAndConsumeJmsByteMessage() throws Exception { + withServer( + server -> { + // #bytearray-sink + // #bytearray-source + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #bytearray-sink + // #bytearray-source + + // #bytearray-sink + Sink> jmsSink = + JmsProducer.bytesSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + byte[] in = "ThisIsATest".getBytes(Charset.forName("UTF-8")); + CompletionStage finished = Source.single(in).runWith(jmsSink, system); + // #bytearray-sink + + // #bytearray-source + Source jmsSource = + JmsConsumer.bytesSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage result = jmsSource.take(1).runWith(Sink.head(), system); + // #bytearray-source + + assertEquals(Done.getInstance(), finished.toCompletableFuture().get(3, TimeUnit.SECONDS)); + byte[] resultArray = result.toCompletableFuture().get(3, TimeUnit.SECONDS); + assertEquals("ThisIsATest", new String(resultArray, Charset.forName("UTF-8"))); + }); + } + + @Test + public void publishAndConsumeJmsMapMessage() throws Exception { + withServer( + server -> { + // #map-sink + // #map-source + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #map-sink + // #map-source + // #map-sink + Sink, CompletionStage> jmsSink = + JmsProducer.mapSink( + JmsProducerSettings.create(system, connectionFactory).withQueue("test")); + + Map in = new HashMap<>(); + in.put("string value", "value"); + in.put("int value", 42); + in.put("double value", 43.0); + in.put("short value", (short) 7); + in.put("boolean value", true); + in.put("long value", 7L); + in.put("bytearray", "AStringAsByteArray".getBytes(Charset.forName("UTF-8"))); + in.put("byte", (byte) 1); + + CompletionStage finished = Source.single(in).runWith(jmsSink, system); + // #map-sink + + // #map-source + Source, JmsConsumerControl> jmsSource = + JmsConsumer.mapSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage> resultStage = + jmsSource.take(1).runWith(Sink.head(), system); + // #map-source + + Map resultMap = + resultStage.toCompletableFuture().get(3, TimeUnit.SECONDS); + + assertEquals(resultMap.get("string value"), in.get("string value")); + assertEquals(resultMap.get("int value"), in.get("int value")); + assertEquals(resultMap.get("double value"), in.get("double value")); + assertEquals(resultMap.get("short value"), in.get("short value")); + assertEquals(resultMap.get("boolean value"), in.get("boolean value")); + assertEquals(resultMap.get("long value"), in.get("long value")); + assertEquals(resultMap.get("byte"), in.get("byte")); + + assertEquals(Done.getInstance(), finished.toCompletableFuture().get(3, TimeUnit.SECONDS)); + byte[] resultByteArray = (byte[]) resultMap.get("bytearray"); + assertEquals(new String(resultByteArray, Charset.forName("UTF-8")), "AStringAsByteArray"); + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithProperties() throws Exception { + withServer( + server -> { + // #jms-source + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #jms-source + + int expectedMessages = 2; + + // #create-jms-sink + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + CompletionStage finished = + Source.from(Arrays.asList("Message A", "Message B")) + .map(JmsTextMessage::create) + .runWith(jmsSink, system); + // #create-jms-sink + + // #jms-source + Source jmsSource = + JmsConsumer.create( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")); + + Pair>> controlAndResult = + jmsSource + .take(expectedMessages) + .map( + msg -> { + if (msg instanceof TextMessage) { + TextMessage t = (TextMessage) msg; + return t.getText(); + } else + throw new RuntimeException("unexpected message type " + msg.getClass()); + }) + .toMat(Sink.seq(), Keep.both()) + .run(system); + + // #jms-source + + CompletionStage> result = controlAndResult.second(); + List outMessages = result.toCompletableFuture().get(3, TimeUnit.SECONDS); + assertEquals("unexpected number of elements", expectedMessages, outMessages.size()); + // #jms-source + JmsConsumerControl control = controlAndResult.first(); + control.shutdown(); + // #jms-source + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithHeaders() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + // #create-messages-with-headers + List msgsIn = + createTestMessageList().stream() + .map( + jmsTextMessage -> + jmsTextMessage + .withHeader(JmsType.create("type")) + .withHeader(JmsCorrelationId.create("correlationId")) + .withHeader(JmsReplyTo.queue("test-reply")) + .withHeader(JmsTimeToLive.create(999, TimeUnit.SECONDS)) + .withHeader(JmsPriority.create(2)) + .withHeader(JmsDeliveryMode.create(DeliveryMode.NON_PERSISTENT))) + .collect(Collectors.toList()); + // #create-messages-with-headers + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.create( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage> result = + jmsSource.take(msgsIn.size()).runWith(Sink.seq(), system); + + List outMessages = result.toCompletableFuture().get(3, TimeUnit.SECONDS); + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(outMsg.getJMSType(), "type"); + assertEquals(outMsg.getJMSCorrelationID(), "correlationId"); + assertEquals(((ActiveMQQueue) outMsg.getJMSReplyTo()).getQueueName(), "test-reply"); + + assertTrue(outMsg.getJMSExpiration() != 0); + assertEquals(2, outMsg.getJMSPriority()); + assertEquals(DeliveryMode.NON_PERSISTENT, outMsg.getJMSDeliveryMode()); + msgIdx++; + } + }); + } + + // #custom-destination + Function createQueue(String destinationName) { + return (session) -> { + ActiveMQSession amqSession = (ActiveMQSession) session; + try { + return amqSession.createQueue("my-" + destinationName); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }; + } + // #custom-destination + + @Test + public void useCustomDesination() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory) + .withDestination(new CustomDestination("custom", createQueue("custom")))); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + // #custom-destination + + Source jmsSource = + JmsConsumer.create( + JmsConsumerSettings.create(system, connectionFactory) + .withDestination(new CustomDestination("custom", createQueue("custom")))); + // #custom-destination + + CompletionStage> result = + jmsSource.take(msgsIn.size()).runWith(Sink.seq(), system); + List outMessages = result.toCompletableFuture().get(3, TimeUnit.SECONDS); + assertEquals(10, outMessages.size()); + }); + } + + @Test + public void publishJmsTextMessagesWithPropertiesAndConsumeThemWithASelector() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + + // #source-with-selector + Source jmsSource = + JmsConsumer.create( + JmsConsumerSettings.create(system, connectionFactory) + .withQueue("test") + .withSelector("IsOdd = TRUE")); + // #source-with-selector + + List oddMsgsIn = + msgsIn.stream() + .filter(msg -> Integer.valueOf(msg.body()) % 2 == 1) + .collect(Collectors.toList()); + assertEquals(5, oddMsgsIn.size()); + + CompletionStage> result = + jmsSource.take(oddMsgsIn.size()).runWith(Sink.seq(), system); + + List outMessages = result.toCompletableFuture().get(4, TimeUnit.SECONDS); + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + oddMsgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + oddMsgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (oddMsgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(1, outMsg.getIntProperty("Number") % 2); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeTopic() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + List inNumbers = + IntStream.range(0, 10).boxed().map(String::valueOf).collect(Collectors.toList()); + + Sink> jmsTopicSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + + Sink> jmsTopicSink2 = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + + Source jmsTopicSource = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, connectionFactory).withTopic("topic")); + + Source jmsTopicSource2 = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, connectionFactory).withTopic("topic")); + + CompletionStage> result = + jmsTopicSource + .take(in.size() + inNumbers.size()) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + + CompletionStage> result2 = + jmsTopicSource2 + .take(in.size() + inNumbers.size()) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + + Thread.sleep(500); + + CompletionStage finished = Source.from(in).runWith(jmsTopicSink, system); + Source.from(inNumbers).runWith(jmsTopicSink2, system); + + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result.toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result2.toCompletableFuture().get(5, TimeUnit.SECONDS)); + }); + } + + @Test + public void sinkNormalCompletion() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings( + ConnectionRetrySettings.create(system).withMaxRetries(0))); + + List msgsIn = createTestMessageList(); + + CompletionStage completionFuture = Source.from(msgsIn).runWith(jmsSink, system); + Done completed = completionFuture.toCompletableFuture().get(3, TimeUnit.SECONDS); + assertEquals(completed, Done.getInstance()); + }); + } + + @Test + public void sinkExceptionalCompletion() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + CompletionStage completionFuture = + Source.failed(new RuntimeException("Simulated error")) + .runWith(jmsSink, system); + + try { + completionFuture.toCompletableFuture().get(3, TimeUnit.SECONDS); + fail("Not completed exceptionally"); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + assertTrue(cause instanceof RuntimeException); + } + }); + } + + @Test + public void sinkExceptionalCompletionOnDisconnect() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings( + ConnectionRetrySettings.create(system).withMaxRetries(0))); + + List msgsIn = createTestMessageList(); + + CompletionStage completionFuture = + Source.from(msgsIn) + .mapAsync( + 1, + m -> + CompletableFuture.supplyAsync( + () -> { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + return m; + })) + .runWith(jmsSink, system); + + try { // Make sure connection got started before stopping. + Thread.sleep(500); + } catch (InterruptedException e) { + fail("Sleep interrupted."); + } + + server.stop(); + + try { + completionFuture.toCompletableFuture().get(3, TimeUnit.SECONDS); + fail("Not completed exceptionally"); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + assertTrue(cause instanceof JMSException); + } + }); + } + + @Test + public void browse() throws Exception { + withServer( + server -> { + // #browse-source + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #browse-source + + Sink> jmsSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + + Source.from(in).runWith(jmsSink, system).toCompletableFuture().get(); + + // #browse-source + Source browseSource = + JmsConsumer.browse( + JmsBrowseSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage> result = + browseSource.runWith(Sink.seq(), system); + // #browse-source + + List resultText = + result.toCompletableFuture().get().stream() + .map( + message -> { + return ((ActiveMQTextMessage) message).getText(); + }) + .collect(Collectors.toList()); + + assertEquals(in, resultText); + }); + } + + @Test + public void publishAndConsumeDurableTopic() throws Exception { + withServer( + server -> { + ConnectionFactory producerConnectionFactory = server.createConnectionFactory(); + // #create-connection-factory-with-client-id + ConnectionFactory consumerConnectionFactory = server.createConnectionFactory(); + ((ActiveMQConnectionFactory) consumerConnectionFactory) + .setClientID(getClass().getSimpleName()); + // #create-connection-factory-with-client-id + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + + Sink> jmsTopicSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, producerConnectionFactory) + .withTopic("topic")); + + // #create-durable-topic-source + Source jmsTopicSource = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, consumerConnectionFactory) + .withDurableTopic("topic", "durable-test")); + // #create-durable-topic-source + + // #run-durable-topic-source + CompletionStage> result = + jmsTopicSource.take(in.size()).runWith(Sink.seq(), system); + // #run-durable-topic-source + + Thread.sleep(500); + + Source.from(in).runWith(jmsTopicSink, system); + + assertEquals(in, result.toCompletableFuture().get(5, TimeUnit.SECONDS)); + }); + } + + @Test + public void producerFlow() throws Exception { + withServer( + server -> { + // #flow-producer + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Flow flow = + JmsProducer.flow( + JmsProducerSettings.create(system, connectionFactory).withQueue("test")); + + List input = createTestMessageList(); + + CompletionStage> result = + Source.from(input).via(flow).runWith(Sink.seq(), system); + // #flow-producer + + assertEquals(input, result.toCompletableFuture().get()); + }); + } + + @Test + public void directedProducerFlow() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #run-directed-flow-producer + Flow flowSink = + JmsProducer.flow( + JmsProducerSettings.create(system, connectionFactory).withQueue("test")); + + List input = new ArrayList<>(); + for (Integer n : Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) { + String queueName = (n % 2 == 0) ? "even" : "odd"; + input.add(JmsTextMessage.create(n.toString()).toQueue(queueName)); + } + + Source.from(input).via(flowSink).runWith(Sink.seq(), system); + // #run-directed-flow-producer + + CompletionStage> even = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("even")) + .take(5) + .map(Integer::parseInt) + .runWith(Sink.seq(), system); + + CompletionStage> odd = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("odd")) + .take(5) + .map(Integer::parseInt) + .runWith(Sink.seq(), system); + + assertEquals(Arrays.asList(1, 3, 5, 7, 9), odd.toCompletableFuture().get()); + assertEquals(Arrays.asList(2, 4, 6, 8, 10), even.toCompletableFuture().get()); + }); + } + + @Test + public void failAfterRetry() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + server.stop(); + long startTime = System.currentTimeMillis(); + CompletionStage> result = + JmsConsumer.create( + JmsConsumerSettings.create(system, connectionFactory) + .withConnectionRetrySettings( + ConnectionRetrySettings.create(system).withMaxRetries(4)) + .withQueue("test")) + .runWith(Sink.seq(), system); + + CompletionStage>> tryFuture = + result.handle( + (l, e) -> { + if (l != null) return Success.apply(l); + else return Failure.apply(e); + }); + + Try> tryResult = tryFuture.toCompletableFuture().get(); + long endTime = System.currentTimeMillis(); + + assertTrue("Total retry is too short", endTime - startTime > 100L + 400L + 900L + 1600L); + assertTrue("Result must be a failure", tryResult.isFailure()); + Throwable exception = tryResult.failed().get(); + assertTrue( + "Did not fail with a ConnectionRetryException", + exception instanceof ConnectionRetryException); + assertTrue( + "Cause of failure is not a JMSException", + exception.getCause() instanceof JMSException); + }); + } + + @Test + public void passThroughMessageEnvelopes() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + // #run-flexi-flow-producer + Flow, JmsEnvelope, JmsProducerStatus> jmsProducer = + JmsProducer.flexiFlow( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List data = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + List> input = new ArrayList<>(); + for (String s : data) { + String passThrough = s; + input.add(JmsTextMessage.create(s, passThrough)); + } + + CompletionStage> result = + Source.from(input) + .via(jmsProducer) + .map(JmsEnvelope::passThrough) + .runWith(Sink.seq(), system); + // #run-flexi-flow-producer + assertEquals(data, result.toCompletableFuture().get()); + }); + } + + @Test + public void passThroughEmptyMessageEnvelopes() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Pair>> switchAndItems = + JmsConsumer.textSource( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")) + .toMat(Sink.seq(), Keep.both()) + .run(system); + + // #run-flexi-flow-pass-through-producer + Flow, JmsEnvelope, JmsProducerStatus> jmsProducer = + JmsProducer.flexiFlow( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List data = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + List> input = new ArrayList<>(); + for (String s : data) { + String passThrough = s; + input.add(JmsPassThrough.create(passThrough)); + } + + CompletionStage> result = + Source.from(input) + .via(jmsProducer) + .map(JmsEnvelope::passThrough) + .runWith(Sink.seq(), system); + // #run-flexi-flow-pass-through-producer + + assertEquals(data, result.toCompletableFuture().get()); + + Thread.sleep(500); + + switchAndItems.first().shutdown(); + assertTrue(switchAndItems.second().toCompletableFuture().get().isEmpty()); + }); + } + + @Test + public void requestReplyWithTempQueues() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Connection connection = connectionFactory.createConnection(); + connection.start(); + Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); + TemporaryQueue tempQueue = session.createTemporaryQueue(); + Destination tempQueueDest = + akka.stream.alpakka.jakartajms.Destination.createDestination(tempQueue); + String message = "ThisIsATest"; + Function reverse = (in) -> new StringBuilder(in).reverse().toString(); + String correlationId = UUID.randomUUID().toString(); + + Sink> toRespondSink = + JmsProducer.sink( + JmsProducerSettings.create(system, connectionFactory).withQueue("test")); + + CompletionStage toRespondStreamCompletion = + Source.single( + JmsTextMessage.create(message) + .withHeader(JmsCorrelationId.create(correlationId)) + .withHeader(new JmsReplyTo(tempQueueDest))) + .runWith(toRespondSink, system); + + // #request-reply + JmsConsumerControl respondStreamControl = + JmsConsumer.create( + JmsConsumerSettings.create(system, connectionFactory).withQueue("test")) + .map(JmsMessageFactory::create) + .collectType(JmsTextMessage.class) + .map( + textMessage -> { + JmsTextMessage m = JmsTextMessage.create(reverse.apply(textMessage.body())); + for (JmsHeader h : textMessage.getHeaders()) + if (h.getClass().equals(JmsReplyTo.class)) + m = m.to(((JmsReplyTo) h).jmsDestination()); + else if (h.getClass().equals(JmsCorrelationId.class)) m = m.withHeader(h); + return m; + }) + .via( + JmsProducer.flow( + JmsProducerSettings.create(system, connectionFactory) + .withQueue("ignored"))) + .to(Sink.ignore()) + .run(system); + // #request-reply + + // getting ConnectionRetryException when trying to listen using streams, assuming it's + // because a different session can't listen on the original session's TemporaryQueue + MessageConsumer consumer = session.createConsumer(tempQueue); + Message msg = consumer.receive(5000); + + assertEquals(Done.done(), toRespondStreamCompletion.toCompletableFuture().get()); + + assertTrue(msg instanceof TextMessage); + assertEquals(correlationId, msg.getJMSCorrelationID()); + assertEquals(reverse.apply(message), ((TextMessage) msg).getText()); + + respondStreamControl.shutdown(); + + connection.close(); + }); + } + + private void withServer(ConsumerChecked test) throws Exception { + JmsBroker broker = JmsBroker.apply(); + try { + test.accept(broker); + Thread.sleep(100); + } finally { + if (broker.isStarted()) { + broker.stop(); + } + } + } + + @FunctionalInterface + private interface ConsumerChecked { + void accept(T elt) throws Exception; + } +} diff --git a/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java b/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java new file mode 100644 index 0000000000..c69210ce6b --- /dev/null +++ b/jakarta-jms/src/test/java/docs/javadsl/JmsSettingsTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.javadsl; + +import akka.stream.alpakka.jakartajms.*; +import akka.stream.alpakka.testkit.javadsl.LogCapturingJunit4; +import com.typesafe.config.ConfigFactory; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.junit.Rule; +import org.junit.Test; + +import java.time.Duration; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; + +// #retry-settings #send-retry-settings +import com.typesafe.config.Config; +import scala.Option; + +// #retry-settings #send-retry-settings + +public class JmsSettingsTest { + + @Rule public final LogCapturingJunit4 logCapturing = new LogCapturingJunit4(); + + @Test + public void producerSettings() throws Exception { + + Config config = ConfigFactory.load(); + // #retry-settings + Config connectionRetryConfig = config.getConfig("alpakka.jakarta-jms.connection-retry"); + // reiterating the values from reference.conf + ConnectionRetrySettings retrySettings = + ConnectionRetrySettings.create(connectionRetryConfig) + .withConnectTimeout(Duration.ofSeconds(10)) + .withInitialRetry(Duration.ofMillis(100)) + .withBackoffFactor(2.0) + .withMaxBackoff(Duration.ofMinutes(1)) + .withMaxRetries(10); + // #retry-settings + + ConnectionRetrySettings retrySettings2 = ConnectionRetrySettings.create(connectionRetryConfig); + assertEquals(retrySettings.toString(), retrySettings2.toString()); + + // #send-retry-settings + Config sendRetryConfig = config.getConfig("alpakka.jakarta-jms.send-retry"); + // reiterating the values from reference.conf + SendRetrySettings sendRetrySettings = + SendRetrySettings.create(sendRetryConfig) + .withInitialRetry(Duration.ofMillis(20)) + .withBackoffFactor(1.5d) + .withMaxBackoff(Duration.ofMillis(500)) + .withMaxRetries(10); + // #send-retry-settings + SendRetrySettings sendRetrySettings2 = SendRetrySettings.create(sendRetryConfig); + assertEquals(sendRetrySettings.toString(), sendRetrySettings2.toString()); + + String brokerUrl = "vm://0"; + // #producer-settings + Config producerConfig = config.getConfig(JmsProducerSettings.configPath()); + JmsProducerSettings settings = + JmsProducerSettings.create(producerConfig, new ActiveMQConnectionFactory(brokerUrl)) + .withTopic("target-topic") + .withCredentials(Credentials.create("username", "password")) + .withConnectionRetrySettings(retrySettings) + .withSendRetrySettings(sendRetrySettings) + .withSessionCount(10) + .withTimeToLive(Duration.ofHours(1)); + // #producer-settings + } + + @Test + public void consumerSettings() throws Exception { + Config config = ConfigFactory.load(); + Config connectionRetryConfig = config.getConfig("alpakka.jakarta-jms.connection-retry"); + ConnectionRetrySettings retrySettings = ConnectionRetrySettings.create(connectionRetryConfig); + + String brokerUrl = "vm://0"; + // #consumer-settings + Config consumerConfig = config.getConfig(JmsConsumerSettings.configPath()); + JmsConsumerSettings settings = + JmsConsumerSettings.create(consumerConfig, new ActiveMQConnectionFactory(brokerUrl)) + .withTopic("message-topic") + .withCredentials(Credentials.create("username", "password")) + .withConnectionRetrySettings(retrySettings) + .withSessionCount(10) + .withAcknowledgeMode(AcknowledgeMode.AutoAcknowledge()) + .withSelector("Important = TRUE"); + // #consumer-settings + assertThat(settings.sessionCount(), is(10)); + assertThat(settings.acknowledgeMode(), is(Option.apply(AcknowledgeMode.AutoAcknowledge()))); + } +} diff --git a/jakarta-jms/src/test/java/docs/javadsl/JmsTxConnectorsTest.java b/jakarta-jms/src/test/java/docs/javadsl/JmsTxConnectorsTest.java new file mode 100644 index 0000000000..dbfb330537 --- /dev/null +++ b/jakarta-jms/src/test/java/docs/javadsl/JmsTxConnectorsTest.java @@ -0,0 +1,385 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.javadsl; + +import akka.Done; +import akka.actor.ActorSystem; +import akka.japi.Pair; +import akka.stream.alpakka.jakartajms.*; +import akka.stream.alpakka.jakartajms.javadsl.JmsConsumer; +import akka.stream.alpakka.jakartajms.javadsl.JmsConsumerControl; +import akka.stream.alpakka.jakartajms.javadsl.JmsProducer; +import akka.stream.alpakka.testkit.javadsl.LogCapturingJunit4; +import akka.stream.javadsl.Sink; +import akka.stream.javadsl.Source; +import akka.testkit.javadsl.TestKit; +import jakartajmstestkit.JmsBroker; +import com.typesafe.config.Config; +import org.apache.activemq.artemis.jms.client.ActiveMQQueue; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +import jakarta.jms.ConnectionFactory; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.TextMessage; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; + +public class JmsTxConnectorsTest { + + @Rule public final LogCapturingJunit4 logCapturing = new LogCapturingJunit4(); + + private static ActorSystem system; + private static Config consumerConfig; + private static Config producerConfig; + + @BeforeClass + public static void setup() { + system = ActorSystem.create(); + consumerConfig = system.settings().config().getConfig(JmsConsumerSettings.configPath()); + producerConfig = system.settings().config().getConfig(JmsProducerSettings.configPath()); + } + + @AfterClass + public static void teardown() { + TestKit.shutdownActorSystem(system); + } + + private List createTestMessageList() { + List intsIn = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + List msgsIn = new ArrayList<>(); + for (Integer n : intsIn) { + msgsIn.add( + JmsTextMessage.create(n.toString()) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0)); + } + + return msgsIn; + } + + @Test + public void publishAndConsume() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + Source.from(in).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.txSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(in.size()) + .map(env -> new Pair<>(env, ((TextMessage) env.message()).getText())) + .map( + pair -> { + pair.first().commit(); + return pair.second(); + }) + .runWith(Sink.seq(), system); + + List out = new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + Collections.sort(out); + assertEquals(in, out); + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithProperties() throws Exception { + withServer( + server -> { + // #source + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + // #source + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + Source.from(msgsIn).runWith(jmsSink, system); + + // #source + Source jmsSource = + JmsConsumer.txSource( + JmsConsumerSettings.create(system, connectionFactory) + .withSessionCount(5) + .withAckTimeout(Duration.ofSeconds(1)) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(msgsIn.size()) + .map( + txEnvelope -> { + txEnvelope.commit(); + return txEnvelope.message(); + }) + .runWith(Sink.seq(), system); + // #source + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeJmsTextMessagesWithHeaders() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = + createTestMessageList().stream() + .map(jmsTextMessage -> jmsTextMessage.withHeader(JmsType.create("type"))) + .map( + jmsTextMessage -> + jmsTextMessage.withHeader(JmsCorrelationId.create("correlationId"))) + .map(jmsTextMessage -> jmsTextMessage.withHeader(JmsReplyTo.queue("test-reply"))) + .collect(Collectors.toList()); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.txSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue("test")); + + CompletionStage> result = + jmsSource + .take(msgsIn.size()) + .map( + env -> { + env.commit(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + msgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + msgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (msgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(outMsg.getJMSType(), "type"); + assertEquals(outMsg.getJMSCorrelationID(), "correlationId"); + assertEquals(((ActiveMQQueue) outMsg.getJMSReplyTo()).getQueueName(), "test-reply"); + msgIdx++; + } + }); + } + + @Test + public void publishJmsTextMessagesWithPropertiesAndConsumeThemWithASelector() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + Sink> jmsSink = + JmsProducer.sink( + JmsProducerSettings.create(producerConfig, connectionFactory).withQueue("test")); + + List msgsIn = createTestMessageList(); + + Source.from(msgsIn).runWith(jmsSink, system); + + Source jmsSource = + JmsConsumer.txSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue("test") + .withSelector("IsOdd = TRUE")); + + List oddMsgsIn = + msgsIn.stream() + .filter(msg -> Integer.valueOf(msg.body()) % 2 == 1) + .collect(Collectors.toList()); + assertEquals(5, oddMsgsIn.size()); + + CompletionStage> result = + jmsSource + .take(oddMsgsIn.size()) + .map( + env -> { + env.commit(); + return env.message(); + }) + .runWith(Sink.seq(), system); + + List outMessages = + new ArrayList<>(result.toCompletableFuture().get(3, TimeUnit.SECONDS)); + outMessages.sort( + (a, b) -> { + try { + return a.getIntProperty("Number") - b.getIntProperty("Number"); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + int msgIdx = 0; + for (Message outMsg : outMessages) { + assertEquals( + outMsg.getIntProperty("Number"), + oddMsgsIn.get(msgIdx).properties().get("Number").get()); + assertEquals( + outMsg.getBooleanProperty("IsOdd"), + oddMsgsIn.get(msgIdx).properties().get("IsOdd").get()); + assertEquals( + outMsg.getBooleanProperty("IsEven"), + (oddMsgsIn.get(msgIdx).properties().get("IsEven").get())); + assertEquals(1, outMsg.getIntProperty("Number") % 2); + msgIdx++; + } + }); + } + + @Test + public void publishAndConsumeTopic() throws Exception { + withServer( + server -> { + ConnectionFactory connectionFactory = server.createConnectionFactory(); + + List in = Arrays.asList("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"); + List inNumbers = + IntStream.range(0, 10).boxed().map(String::valueOf).collect(Collectors.toList()); + + Sink> jmsTopicSink = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + + Sink> jmsTopicSink2 = + JmsProducer.textSink( + JmsProducerSettings.create(producerConfig, connectionFactory).withTopic("topic")); + + Source jmsTopicSource = + JmsConsumer.txSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(1) + .withTopic("topic")); + + Source jmsTopicSource2 = + JmsConsumer.txSource( + JmsConsumerSettings.create(consumerConfig, connectionFactory) + .withSessionCount(1) + .withTopic("topic")); + + CompletionStage> result = + jmsTopicSource + .take(in.size() + inNumbers.size()) + .map( + env -> { + env.commit(); + return ((TextMessage) env.message()).getText(); + }) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + + CompletionStage> result2 = + jmsTopicSource2 + .take(in.size() + inNumbers.size()) + .map( + env -> { + env.commit(); + return ((TextMessage) env.message()).getText(); + }) + .runWith(Sink.seq(), system) + .thenApply(l -> l.stream().sorted().collect(Collectors.toList())); + + Thread.sleep(500); + + Source.from(in).runWith(jmsTopicSink, system); + + Source.from(inNumbers).runWith(jmsTopicSink2, system); + + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result.toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertEquals( + Stream.concat(in.stream(), inNumbers.stream()).sorted().collect(Collectors.toList()), + result2.toCompletableFuture().get(5, TimeUnit.SECONDS)); + }); + } + + private void withServer(ConsumerChecked test) throws Exception { + JmsBroker broker = JmsBroker.apply(); + try { + test.accept(broker); + Thread.sleep(500); + } finally { + if (broker.isStarted()) { + broker.stop(); + } + } + } + + @FunctionalInterface + private interface ConsumerChecked { + void accept(T elt) throws Exception; + } +} diff --git a/jakarta-jms/src/test/resources/application.conf b/jakarta-jms/src/test/resources/application.conf new file mode 100644 index 0000000000..7bdde8d7f2 --- /dev/null +++ b/jakarta-jms/src/test/resources/application.conf @@ -0,0 +1,5 @@ +akka { + loggers = ["akka.event.slf4j.Slf4jLogger"] + logging-filter = "akka.event.slf4j.Slf4jLoggingFilter" + loglevel = "DEBUG" +} diff --git a/jakarta-jms/src/test/resources/logback-test.xml b/jakarta-jms/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..2e50812388 --- /dev/null +++ b/jakarta-jms/src/test/resources/logback-test.xml @@ -0,0 +1,29 @@ + + + target/jms.log + false + + %d{ISO8601} %-5level [%thread] [%logger{36}] %msg%n + + + + + + %d{HH:mm:ss.SSS} %-5level [%-20.20thread] %-36.36logger{36} %msg%n%rEx + + + + + + + + + + + + + + + + + diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsConnectionStatusSpec.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsConnectionStatusSpec.scala new file mode 100644 index 0000000000..a96ef12974 --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsConnectionStatusSpec.scala @@ -0,0 +1,386 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger + +import akka.stream.OverflowStrategy +import akka.stream.alpakka.jakartajms.scaladsl.JmsConnectorState._ +import akka.stream.alpakka.jakartajms.scaladsl.{JmsConnectorState, JmsConsumer, JmsProducer, JmsProducerStatus} +import akka.stream.scaladsl.{Flow, Keep, Sink, SinkQueueWithCancel, Source} +import jakarta.jms._ +import org.mockito.ArgumentMatchers.{any, anyBoolean, anyInt} +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer +import org.scalatest.matchers.{MatchResult, Matcher} + +import scala.concurrent.duration._ + +class JmsConnectionStatusSpec extends JmsSpec { + + override implicit val patienceConfig: PatienceConfig = PatienceConfig(10.seconds, 50.millis) + + "JmsConnector connection status" should { + + "report disconnected on producer stream failure" in withConnectionFactory() { connectionFactory => + val connectedLatch = new CountDownLatch(1) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test")) + val exception = new RuntimeException("failing stage") + + val producerStatus = Source + .tick(10.millis, 20.millis, "text") + .zipWithIndex + .map { x => + if (x._2 == 3) { + connectedLatch.await() + throw exception + } + x._1 + } + .runWith(jmsSink) + + val status = producerStatus.connectorState.toMat(Sink.queue())(Keep.right).run() + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Connected) + + connectedLatch.countDown() + + status should havePublishedState(Failing(exception)) + status should havePublishedState(Failed(exception)) + } + + "report multiple connection attempts" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(factory.createConnection()).thenAnswer(new Answer[Connection]() { + override def answer(invocation: InvocationOnMock): Connection = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else connection + }) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, factory).withQueue("test")) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "report failure when running out of connection attempts" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + val exception = new JMSException("connect error") + val retryException = ConnectionRetryException("Could not establish connection after 1 retries.", exception) + when(factory.createConnection()).thenAnswer(new Answer[Connection]() { + override def answer(invocation: InvocationOnMock): Connection = + if (connectAttempts.getAndIncrement() < 2) throw exception else connection + }) + + val jmsSink = textSink( + JmsProducerSettings(producerConfig, factory) + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(1)) + .withQueue("test") + ) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Failing(retryException)) + status should havePublishedState(Failed(retryException)) + } + + "retry connection when creating session fails" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(connection.createSession(anyBoolean(), anyInt())).thenAnswer(new Answer[Session]() { + override def answer(invocation: InvocationOnMock): Session = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else session + }) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, factory).withQueue("test")) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "abort connection on security exception" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + val securityException = new JMSSecurityException("security error") + when(factory.createConnection()).thenAnswer(new Answer[Connection]() { + override def answer(invocation: InvocationOnMock): Connection = + if (connectAttempts.getAndIncrement() == 0) throw securityException else connection + }) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, factory).withQueue("test")) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + eventually { status should havePublishedState(Failing(securityException)) } + status should havePublishedState(Failed(securityException)) + } + + "retry connection when creating producer fails" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(session.createProducer(any[jakarta.jms.Destination])).thenAnswer(new Answer[MessageProducer]() { + override def answer(invocation: InvocationOnMock): MessageProducer = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else producer + }) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, factory).withQueue("test")) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "retry connection when setting exception listener fails" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(connection.setExceptionListener(any[ExceptionListener]())).thenAnswer(new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else () + }) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, factory).withQueue("test")) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "retry connection when creating producer destination fails" in withMockedProducer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(session.createQueue(any[String])).thenAnswer(new Answer[jakarta.jms.Queue]() { + override def answer(invocation: InvocationOnMock): jakarta.jms.Queue = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else queue + }) + + val jmsSink = textSink(JmsProducerSettings(producerConfig, factory).withQueue("test")) + val connectionStatus = Source.tick(10.millis, 20.millis, "text").runWith(jmsSink).connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "retry connection when creating consumer fails" in withMockedConsumer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(session.createConsumer(any[jakarta.jms.Destination])).thenAnswer(new Answer[MessageConsumer]() { + override def answer(invocation: InvocationOnMock): MessageConsumer = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else consumer + }) + + val jmsSource = JmsConsumer.textSource(JmsConsumerSettings(system, factory).withQueue("test")) + val connectionStatus = jmsSource.toMat(Sink.ignore)(Keep.left).run().connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "retry connection when creating consumer destination fails" in withMockedConsumer { ctx => + import ctx._ + val connectAttempts = new AtomicInteger() + when(session.createQueue(any[String])).thenAnswer(new Answer[jakarta.jms.Queue]() { + override def answer(invocation: InvocationOnMock): jakarta.jms.Queue = + if (connectAttempts.getAndIncrement() == 0) throw new JMSException("connect error") else queue + }) + + val jmsSource = JmsConsumer.textSource(JmsConsumerSettings(system, factory).withQueue("test")) + val connectionStatus = jmsSource.toMat(Sink.ignore)(Keep.left).run().connectorState + val status = connectionStatus.runWith(Sink.queue()) + + status should havePublishedState(Connecting(1)) + status should havePublishedState(Disconnected) + status should havePublishedState(Connecting(2)) + status should havePublishedState(Connected) + } + + "report disconnected on consumer stream failure" in withConnectionFactory() { connectionFactory => + val connectedLatch = new CountDownLatch(1) + val exception = new RuntimeException("failing stage") + + val jmsSink = textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test")) + + val jmsSource = JmsConsumer.textSource(JmsConsumerSettings(system, connectionFactory).withQueue("test")) + + val consumerControl = jmsSource.zipWithIndex + .map { x => + if (x._2 == 3) { + connectedLatch.await() + throw exception + } + x._1 + } + .toMat(Sink.ignore)(Keep.left) + .run() + + Source.tick(10.millis, 20.millis, "text").runWith(jmsSink) + + val status = consumerControl.connectorState.runWith(Sink.queue()) + + eventually { status should havePublishedState(Connected) } + + connectedLatch.countDown() + + eventually { status should havePublishedState(Completing) } + eventually { status should havePublishedState(Completed) } + } + + "report completion on stream shutdown / cancel" in withConnectionFactory() { connectionFactory => + val jmsSink = textSink( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("test") + ) + + val jmsSource = JmsConsumer.textSource( + JmsConsumerSettings(system, connectionFactory) + .withQueue("test") + ) + + val (cancellable, producerStatus) = Source.tick(10.millis, 20.millis, "text").toMat(jmsSink)(Keep.both).run() + val consumerControl = jmsSource.toMat(Sink.ignore)(Keep.left).run() + + val consumerConnected = consumerControl.connectorState.runWith(Sink.queue()) + val producerConnected = producerStatus.connectorState.runWith(Sink.queue()) + + eventually { consumerConnected should havePublishedState(Connected) } + eventually { producerConnected should havePublishedState(Connected) } + + cancellable.cancel() + consumerControl.shutdown() + + eventually { consumerConnected should havePublishedState(Completing) } + eventually { consumerConnected should havePublishedState(Completed) } + + eventually { producerConnected should havePublishedState(Completing) } + eventually { producerConnected should havePublishedState(Completed) } + } + + "report failure on external stream abort" in withConnectionFactory() { connectionFactory => + val exception = new RuntimeException("aborting stream") + + val jmsSource = JmsConsumer.textSource( + JmsConsumerSettings(system, connectionFactory) + .withQueue("test") + ) + + val consumerControl = jmsSource.toMat(Sink.ignore)(Keep.left).run() + + val consumerConnected = consumerControl.connectorState.runWith(Sink.queue()) + + eventually { consumerConnected should havePublishedState(Connected) } + + consumerControl.abort(exception) + + consumerConnected should havePublishedState(Failing(exception)) + consumerConnected should havePublishedState(Failed(exception)) + } + + "reflect connection status on connection retries" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val jmsSink = textSink( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings( + ConnectionRetrySettings(system) + .withConnectTimeout(1.second) + .withInitialRetry(100.millis) + .withMaxBackoff(100.millis) + .withInfiniteRetries() + ) + .withSendRetrySettings(SendRetrySettings(system).withInfiniteRetries()) + ) + + val jmsSource = JmsConsumer.textSource( + JmsConsumerSettings(system, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings( + ConnectionRetrySettings(system) + .withConnectTimeout(1.second) + .withInitialRetry(100.millis) + .withMaxBackoff(100.millis) + .withInfiniteRetries() + ) + ) + + val producerStatus = Source.tick(50.millis, 100.millis, "text").runWith(jmsSink) + val consumerControl = jmsSource.toMat(Sink.ignore)(Keep.left).run() + + val consumerConnected = + consumerControl.connectorState.buffer(10, OverflowStrategy.backpressure).runWith(Sink.queue()) + val producerConnected = + producerStatus.connectorState.buffer(10, OverflowStrategy.backpressure).runWith(Sink.queue()) + + for (_ <- 1 to 20) { + eventually { consumerConnected should havePublishedState(Connected) } + eventually { producerConnected should havePublishedState(Connected) } + + server.stop() + + eventually { consumerConnected should havePublishedState(Disconnected) } + eventually { producerConnected should havePublishedState(Disconnected) } + + server.restart() + } + + eventually { consumerConnected should havePublishedState(Connected) } + eventually { producerConnected should havePublishedState(Connected) } + } + } + + private def textSink(settings: JmsProducerSettings): Sink[String, JmsProducerStatus] = + Flow[String] + .map(s => JmsTextMessage(s)) + .viaMat(JmsProducer.flow(settings))(Keep.right) + .to(Sink.ignore) + + class ConnectionStateMatcher(expectedState: JmsConnectorState) + extends Matcher[SinkQueueWithCancel[JmsConnectorState]] { + + def apply(queue: SinkQueueWithCancel[JmsConnectorState]): MatchResult = + queue.pull().futureValue match { + case Some(state) => + MatchResult( + state == expectedState, + s"""Published connection state $state was not $expectedState""", + s"""Published connection state $state was $expectedState""" + ) + case None => + MatchResult( + matches = false, + s"""Did not publish connection state. Expected was $expectedState""", + s"""Published connection state""" + ) + } + } + + def havePublishedState(expectedState: JmsConnectorState) = + new ConnectionStateMatcher(expectedState: JmsConnectorState) +} diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsProducerRetrySpec.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsProducerRetrySpec.scala new file mode 100644 index 0000000000..05169e771c --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsProducerRetrySpec.scala @@ -0,0 +1,225 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import java.util.concurrent.atomic.AtomicInteger + +import akka.stream._ +import akka.stream.alpakka.jakartajms.scaladsl.{JmsConsumer, JmsProducer} +import akka.stream.scaladsl.{Keep, Sink, Source} +import jakarta.jms.{JMSException, Message, TextMessage} +import org.mockito.ArgumentMatchers.{any, anyInt, anyLong} +import org.mockito.Mockito.{mock, when} +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer + +import scala.concurrent.duration._ + +class JmsProducerRetrySpec extends JmsSpec { + + override implicit val patienceConfig: PatienceConfig = PatienceConfig(20.seconds) + val stoppingDecider: Supervision.Decider = ex => Supervision.Stop + + "JmsProducer retries" should { + "retry sending on network failures" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val jms = JmsProducer + .flow[JmsMapMessage]( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("test") + .withSessionCount(3) + .withConnectionRetrySettings( + ConnectionRetrySettings(system) + .withConnectTimeout(100.millis) + .withInitialRetry(50.millis) + .withMaxBackoff(50.millis) + .withInfiniteRetries() + ) + .withSendRetrySettings( + SendRetrySettings(system).withInitialRetry(10.millis).withMaxBackoff(10.millis).withInfiniteRetries() + ) + ) + .withAttributes(ActorAttributes.supervisionStrategy(stoppingDecider)) + + val (queue, result) = Source + .queue[Int](10, OverflowStrategy.backpressure) + .zipWithIndex + .map(e => JmsMapMessage(Map("time" -> System.currentTimeMillis(), "index" -> e._2))) + .via(jms) + .map(_.body) + .toMat(Sink.seq)(Keep.both) + .run() + + val sentResult = JmsConsumer + .mapSource(JmsConsumerSettings(system, connectionFactory).withBufferSize(1).withQueue("test")) + .take(20) + .runWith(Sink.seq) + + for (_ <- 1 to 10) queue.offer(1) // 10 before the crash + Thread.sleep(500) + server.stop() // crash. + + Thread.sleep(1000) + server.restart() // recover. + val restartTime = System.currentTimeMillis() + for (_ <- 1 to 10) queue.offer(1) // 10 after the crash + queue.complete() + + val resultList = result.futureValue + def index(m: Map[String, Any]) = m("index").asInstanceOf[Long] + def time(m: Map[String, Any]) = m("time").asInstanceOf[Long] + + resultList.size shouldBe 20 + resultList.filter(b => time(b) >= restartTime) shouldNot be(empty) + resultList.sliding(2).forall(pair => index(pair.head) + 1 == index(pair.last)) shouldBe true + + val sentList = sentResult.futureValue + sentList.size shouldBe 20 + // all produced elements should have been sent to the consumer. + resultList.forall { produced => + sentList.exists(consumed => index(consumed) == index(produced)) + } shouldBe true + } + + "fail sending only after max retries" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val jms = JmsProducer + .flow[JmsMapMessage]( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withInfiniteRetries()) + .withSendRetrySettings( + SendRetrySettings(system) + .withInitialRetry(100.millis) + .withMaxBackoff(600.millis) + .withBackoffFactor(2) + .withMaxRetries(3) + ) + ) + .withAttributes(ActorAttributes.supervisionStrategy(stoppingDecider)) + + val (cancellable, result) = Source + .tick(50.millis, 50.millis, "") + .zipWithIndex + .map(e => JmsMapMessage(Map("time" -> System.currentTimeMillis(), "index" -> e._2))) + .via(jms) + .map(_.body) + .toMat(Sink.seq)(Keep.both) + .run() + + Thread.sleep(500) + val crashTime = System.currentTimeMillis() + server.stop() + val failure = result.failed.futureValue + val failureTime = System.currentTimeMillis() + + val expectedDelay = 100L + 400L + 600L + failureTime - crashTime shouldBe >(expectedDelay) + failure shouldBe RetrySkippedOnMissingConnection + } + + "fail immediately on non-recoverable errors" in withConnectionFactory() { connectionFactory => + val jms = JmsProducer + .flow[JmsMapMessage]( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("test") + .withSendRetrySettings(SendRetrySettings(system).withInfiniteRetries()) + ) + .withAttributes(ActorAttributes.supervisionStrategy(stoppingDecider)) + + val result = Source( + List(JmsMapMessage(Map("body" -> "1")), JmsMapMessage(Map("body" -> this)), JmsMapMessage(Map("body" -> "3"))) + ).via(jms) + .map(_.body("body").toString) + .runWith(Sink.seq) + + val failure = result.failed.futureValue + failure shouldBe a[UnsupportedMapMessageEntryType] + } + + "invoke supervisor when send fails" in withConnectionFactory() { connectionFactory => + val deciderCalls = new AtomicInteger() + val decider: Supervision.Decider = { ex => + deciderCalls.incrementAndGet() + Supervision.Resume + } + + val jms = JmsProducer + .flow[JmsMapMessage]( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("test") + .withSendRetrySettings(SendRetrySettings(system).withInfiniteRetries()) + ) + .withAttributes(ActorAttributes.supervisionStrategy(decider)) + + // second element is a wrong map message. + val result = Source( + List(JmsMapMessage(Map("body" -> "1")), JmsMapMessage(Map("body" -> this)), JmsMapMessage(Map("body" -> "3"))) + ).via(jms) + .map(_.body("body").toString) + .runWith(Sink.seq) + + // check that second element was skipped. + val list = result.futureValue + list shouldBe List("1", "3") + + deciderCalls.get shouldBe 1 + } + + "retry send as often as configured" in withMockedProducer { ctx => + import ctx._ + val sendAttempts = new AtomicInteger() + val message = mock(classOf[TextMessage]) + + when(session.createTextMessage(any[String])).thenReturn(message) + + when(producer.send(any[jakarta.jms.Destination], any[Message], anyInt(), anyInt(), anyLong())) + .thenAnswer(new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = { + sendAttempts.incrementAndGet() + throw new JMSException("send error") + } + }) + + val jms = JmsProducer.textSink( + JmsProducerSettings(producerConfig, factory) + .withQueue("test") + .withSendRetrySettings( + SendRetrySettings(system).withInitialRetry(10.millis).withMaxBackoff(10.millis).withMaxRetries(5) + ) + ) + + val result = Source(List("one")).runWith(jms) + + result.failed.futureValue shouldBe a[JMSException] + sendAttempts.get shouldBe 6 + } + + "fail send on first attempt if retry is disabled" in withMockedProducer { ctx => + import ctx._ + val sendAttempts = new AtomicInteger() + val message = mock(classOf[TextMessage]) + + when(session.createTextMessage(any[String])).thenReturn(message) + + when(producer.send(any[jakarta.jms.Destination], any[Message], anyInt(), anyInt(), anyLong())) + .thenAnswer(new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = + if (sendAttempts.incrementAndGet() == 1) throw new JMSException("send error") + }) + + val jms = JmsProducer.textSink( + JmsProducerSettings(producerConfig, factory) + .withQueue("test") + .withSendRetrySettings(SendRetrySettings(system).withMaxRetries(0)) + ) + + val result = Source(List("one")).runWith(jms) + + result.failed.futureValue shouldBe a[JMSException] + sendAttempts.get shouldBe 1 + } + } +} diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsSpec.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsSpec.scala new file mode 100644 index 0000000000..22372f85cd --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/JmsSpec.scala @@ -0,0 +1,84 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms + +import scala.util.Random + +import akka.actor.ActorSystem +import akka.stream.alpakka.testkit.scaladsl.LogCapturing +import akka.testkit.TestKit +import jakartajmstestkit.JmsBroker +import org.mockito.ArgumentMatchers.{any, anyBoolean, anyInt} +import org.mockito.Mockito.{mock, when} +import org.scalatest.concurrent.{Eventually, ScalaFutures} +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach} +import jakarta.jms._ + +abstract class JmsSpec + extends AnyWordSpec + with Matchers + with BeforeAndAfterAll + with BeforeAndAfterEach + with ScalaFutures + with Eventually + with LogCapturing { + + implicit val system: ActorSystem = ActorSystem(this.getClass.getSimpleName) + + val consumerConfig = system.settings.config.getConfig(JmsConsumerSettings.configPath) + val producerConfig = system.settings.config.getConfig(JmsProducerSettings.configPath) + val browseConfig = system.settings.config.getConfig(JmsBrowseSettings.configPath) + + override protected def afterAll(): Unit = + TestKit.shutdownActorSystem(system) + + def withConnectionFactory()(test: ConnectionFactory => Unit): Unit = + withServer() { server => + test(server.createConnectionFactory) + } + + def withServer()(test: JmsBroker => Unit): Unit = { + val jmsBroker = JmsBroker() + try { + test(jmsBroker) + Thread.sleep(500) + } finally { + if (jmsBroker.isStarted) { + jmsBroker.stop() + } + } + } + + def withMockedProducer(test: ProducerMock => Unit): Unit = test(ProducerMock()) + + case class ProducerMock(factory: ConnectionFactory = mock(classOf[ConnectionFactory]), + connection: Connection = mock(classOf[Connection]), + session: Session = mock(classOf[Session]), + producer: MessageProducer = mock(classOf[MessageProducer]), + queue: jakarta.jms.Queue = mock(classOf[jakarta.jms.Queue])) { + when(factory.createConnection()).thenReturn(connection) + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + when(session.createProducer(any[jakarta.jms.Destination])).thenReturn(producer) + when(session.createQueue(any[String])).thenReturn(queue) + } + + case class ConsumerMock(factory: ConnectionFactory = mock(classOf[ConnectionFactory]), + connection: Connection = mock(classOf[Connection]), + session: Session = mock(classOf[Session]), + consumer: MessageConsumer = mock(classOf[MessageConsumer]), + queue: jakarta.jms.Queue = mock(classOf[jakarta.jms.Queue])) { + when(factory.createConnection()).thenReturn(connection) + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + when(session.createConsumer(any[jakarta.jms.Destination])).thenReturn(consumer) + when(session.createQueue(any[String])).thenReturn(queue) + } + + def withMockedConsumer(test: ConsumerMock => Unit): Unit = test(ConsumerMock()) + + def createName(prefix: String) = prefix + Random.nextInt().toString + +} diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageProducerSpec.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageProducerSpec.scala new file mode 100644 index 0000000000..145b19f4e5 --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/impl/JmsMessageProducerSpec.scala @@ -0,0 +1,155 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl + +import akka.stream.alpakka.jakartajms.{Destination, _} +import jakarta.jms.{Destination => JmsDestination, _} +import org.mockito.ArgumentMatchers.{any, anyBoolean, anyInt, anyString} +import org.mockito.Mockito._ + +class JmsMessageProducerSpec extends JmsSpec { + + trait Setup { + val factory: ConnectionFactory = mock(classOf[ConnectionFactory]) + val connection: Connection = mock(classOf[Connection]) + val session: Session = mock(classOf[Session]) + val destination: JmsDestination = mock(classOf[JmsDestination]) + val producer: MessageProducer = mock(classOf[MessageProducer]) + val textMessage: TextMessage = mock(classOf[TextMessage]) + val mapMessage: MapMessage = mock(classOf[MapMessage]) + val settingsDestination: Destination = mock(classOf[Destination]) + + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + when(session.createProducer(any[jakarta.jms.Destination])).thenReturn(producer) + when(session.createTextMessage(anyString())).thenReturn(textMessage) + when(session.createMapMessage()).thenReturn(mapMessage) + + val settings = JmsProducerSettings(producerConfig, factory).withDestination(settingsDestination) + val jmsSession = new JmsProducerSession(connection, session, destination) + + } + + "populating Jms message properties" should { + "succeed if properties are set to supported types" in new Setup { + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + jmsProducer.populateMessageProperties( + textMessage, + JmsTextMessage("test") + .withProperty("string", "string") + .withProperty("int", 1) + .withProperty("boolean", true) + .withProperty("byte", 2.toByte) + .withProperty("short", 3.toShort) + .withProperty("long", 4L) + .withProperty("double", 5.0) + .withProperty("bytearray", Array[Byte](1, 1, 0)) + ) + + verify(textMessage).setStringProperty("string", "string") + verify(textMessage).setIntProperty("int", 1) + verify(textMessage).setBooleanProperty("boolean", true) + verify(textMessage).setByteProperty("byte", 2.toByte) + verify(textMessage).setShortProperty("short", 3.toByte) + verify(textMessage).setLongProperty("long", 4L) + verify(textMessage).setDoubleProperty("double", 5.0) + verify(textMessage).setObjectProperty("bytearray", Array[Byte](1, 1, 0)) + } + + "succeed if properties are set as map" in new Setup { + val props = Map[String, Any]( + "string" -> "string", + "int" -> 1, + "boolean" -> true, + "byte" -> 2.toByte, + "short" -> 3.toShort, + "float" -> 4.78f, + "Java-boxed float" -> java.lang.Float.valueOf(4.35f), + "long" -> 4L, + "Java-boxed long" -> java.lang.Long.valueOf(44L), + "double" -> 5.0, + "bytearray" -> Array[Byte](1, 1, 0) + ) + + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + jmsProducer.populateMessageProperties( + textMessage, + JmsTextMessage("test") + .withProperties(props) + ) + + verify(textMessage).setStringProperty("string", "string") + verify(textMessage).setIntProperty("int", 1) + verify(textMessage).setBooleanProperty("boolean", true) + verify(textMessage).setByteProperty("byte", 2.toByte) + verify(textMessage).setShortProperty("short", 3.toByte) + verify(textMessage).setFloatProperty("float", 4.78f) + verify(textMessage).setFloatProperty("Java-boxed float", 4.35f) + verify(textMessage).setLongProperty("long", 4L) + verify(textMessage).setLongProperty("Java-boxed long", 44L) + verify(textMessage).setDoubleProperty("double", 5.0) + verify(textMessage).setObjectProperty("bytearray", Array[Byte](1, 1, 0)) + } + + "fail if a property is set to an unsupported type" in new Setup { + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + assertThrows[UnsupportedMessagePropertyType] { + jmsProducer.populateMessageProperties(textMessage, JmsTextMessage("test").withProperty("object", this)) + } + } + + "succeed if a property is set to a null value" in new Setup { + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + jmsProducer.populateMessageProperties(textMessage, JmsTextMessage("test").withProperty("object", null)) + verify(textMessage).setObjectProperty("object", null) + } + } + + "creating a Jms Map message" should { + "succeed if map values are supported types" in new Setup { + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + jmsProducer.createMessage( + JmsMapMessage( + Map( + "string" -> "string", + "int" -> 1, + "boolean" -> true, + "byte" -> 2.toByte, + "short" -> 3.toShort, + "float" -> 4.89f, + "Java-boxed float" -> java.lang.Float.valueOf(4.35f), + "long" -> 4L, + "Java-boxed long" -> java.lang.Long.valueOf(44L), + "double" -> 5.0 + ) + ) + ) + verify(mapMessage).setString("string", "string") + verify(mapMessage).setInt("int", 1) + verify(mapMessage).setBoolean("boolean", true) + verify(mapMessage).setByte("byte", 2.toByte) + verify(mapMessage).setShort("short", 3.toByte) + verify(mapMessage).setFloat("float", 4.89f) + verify(mapMessage).setFloat("Java-boxed float", 4.35f) + verify(mapMessage).setLong("long", 4L) + verify(mapMessage).setLong("Java-boxed long", 44L) + verify(mapMessage).setDouble("double", 5.0) + } + + "fail if a map value is set to an unsupported type" in new Setup { + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + assertThrows[UnsupportedMapMessageEntryType] { + val wrongMap: Map[String, Any] = Map("object" -> this) + jmsProducer.createMessage(JmsMapMessage(wrongMap)) + } + } + + "succeed if a map value is set to null" in new Setup { + val jmsProducer = JmsMessageProducer(jmsSession, settings, 0) + val correctMap: Map[String, Any] = Map("object" -> null) + jmsProducer.createMessage(JmsMapMessage(correctMap)) + verify(mapMessage).setObject("object", null) + } + } +} diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/impl/SoftReferenceCacheSpec.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/impl/SoftReferenceCacheSpec.scala new file mode 100644 index 0000000000..9d73ac4f24 --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/impl/SoftReferenceCacheSpec.scala @@ -0,0 +1,135 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.impl +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.util.concurrent.Executors +import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} +import scala.concurrent.duration._ +import scala.concurrent.{ExecutionContext, ExecutionContextExecutorService, Future} + +class SoftReferenceCacheSpec extends AnyWordSpec with Matchers { + + "soft reference cache lookup" should { + "return default value on miss" in { + val cache = new SoftReferenceCache[Int, String] + cache.lookup(1, "one") shouldBe "one" + } + + "return previous value on hit" in { + System.gc() // if memory pressure exists, reduce it now. + val cache = new SoftReferenceCache[Int, String] + cache.lookup(1, "one") + cache.lookup(1, "two") shouldBe "one" + } + + "not evaluate default value on hit" in { + System.gc() // if memory pressure exists, reduce it now. + val cache = new SoftReferenceCache[Int, String] + cache.lookup(1, "one") + cache.lookup(1, throw new RuntimeException("Should not be evaluated")) shouldBe "one" + } + + "remove entries on garbage collection" in { + val cache = new SoftReferenceCache[Int, Array[Byte]] + + val deadline = System.currentTimeMillis() + 1.minute.toMillis // try for 1 minute. + var i = 1 + + def addCacheEntries(): Unit = for (_ <- 1 to 40) { + cache.lookup(i, new Array[Byte](1024 * 1024)) + i += 1 + } + + val newValue = Array.fill(1024)(1.toByte) + + // detect eviction by inserting a different value into the cache for a previously set key. + def entryEvicted(index: Int): Boolean = cache.lookup(index, newValue).length == 1024 + + def noEntryEvicted: Boolean = !(1 until i).exists(entryEvicted) + + while (noEntryEvicted && System.currentTimeMillis() < deadline) { + addCacheEntries() + System.gc() + } + + noEntryEvicted shouldBe false + } + + "not need synchronization in intended usage scenario" in { + // simulates the JmsProducerStage / JmsMessageProducer behavior to give some + // evidence that the cache synchronization isn't needed when used in JmsMessageProducer + + // setup utilities + implicit val ec: ExecutionContextExecutorService = + ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(16)) + + val stop = new AtomicBoolean(false) + val failed = new AtomicBoolean(false) + + // setup cache under test + type Cache = SoftReferenceCache[Long, String] + class State(val cache: Cache = new Cache, var counter: Long = 0L) + val ref = new AtomicReference(Option(new State())) + ref.get.get.cache.lookup(0L, "0") + + // dequeue/enqueue simulates memory visibility guarantees of Akka's async callbacks + def dequeue(): Option[State] = { + val seen = ref.get + seen.filter(_ => ref.compareAndSet(seen, None)) + } + + def enqueue(state: State): Unit = ref.set(Some(state)) + + // run test + for (_ <- 1 to 4) + Future { + while (!stop.get()) { + dequeue().foreach { state => + val count = state.counter + 1 + val cache = state.cache + Future { + // no atomic reference operations on the happy path of the future itself + val past = cache.lookup(count - 1, "wrong") + cache.lookup(count, count.toString) + if (past == "wrong") { + info(s"Worker did not see past update on key '${count - 1}' and was able to set wrong cache entry") + // note that these atomic reference operations are only executed when something went wrong already + failed.set(true) + stop.set(true) + } + state.counter = count + state + }.foreach(enqueue)(akka.dispatch.ExecutionContexts.parasitic) + } + } + } + + // stop test + Future { + Thread.sleep(10.seconds.toMillis) + stop.set(true) + } + + Thread.sleep(9.seconds.toMillis) + while (!stop.get()) { + Thread.sleep(100) + } + ec.shutdown() + + while (ref.get.isEmpty) { + Thread.sleep(10) + } + + info(s"Executed ${ref.get.get.counter} cache lookups") + + // verify + if (failed.get()) { + fail("Synchronization was broken") + } + } + } +} diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/scaladsl/CachedConnectionFactory.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/scaladsl/CachedConnectionFactory.scala new file mode 100644 index 0000000000..1b98ebeecc --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/scaladsl/CachedConnectionFactory.scala @@ -0,0 +1,33 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.scaladsl + +import jakarta.jms.{Connection, ConnectionFactory} + +import org.apache.activemq.artemis.jms.client.ActiveMQConnection + +/** + * a silly cached connection factory, not thread safe + */ +class CachedConnectionFactory(connFactory: ConnectionFactory) extends ConnectionFactory { + + var cachedConnection: ActiveMQConnection = null + + override def createConnection(): Connection = { + if (cachedConnection == null) { + cachedConnection = connFactory.createConnection().asInstanceOf[ActiveMQConnection] + } + cachedConnection + } + + override def createConnection(s: String, s1: String): Connection = cachedConnection + + // added in JMS 2.0 + // see https://github.com/akka/alpakka/issues/1493 + def createContext(x$1: Int): jakarta.jms.JMSContext = ??? + def createContext(x$1: String, x$2: String, x$3: Int): jakarta.jms.JMSContext = ??? + def createContext(x$1: String, x$2: String): jakarta.jms.JMSContext = ??? + def createContext(): jakarta.jms.JMSContext = ??? +} diff --git a/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsAckConnectorsSpec.scala b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsAckConnectorsSpec.scala new file mode 100644 index 0000000000..3ae160b174 --- /dev/null +++ b/jakarta-jms/src/test/scala/akka/stream/alpakka/jakartajms/scaladsl/JmsAckConnectorsSpec.scala @@ -0,0 +1,460 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package akka.stream.alpakka.jakartajms.scaladsl + +import java.util.concurrent.{LinkedBlockingQueue, TimeUnit} + +import akka.Done +import akka.stream.alpakka.jakartajms._ +import akka.stream.scaladsl.{Flow, Keep, Sink, Source} +import akka.stream.testkit.scaladsl.TestSink +import akka.stream.{KillSwitches, ThrottleMode} +import jakarta.jms.{JMSException, TextMessage} +import org.scalatest.Inspectors._ +import org.scalatest.time.Span.convertSpanToDuration + +import scala.annotation.tailrec +import scala.collection.mutable +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +class JmsAckConnectorsSpec extends JmsSpec { + + override implicit val patienceConfig: PatienceConfig = PatienceConfig(2.minutes) + + "The JMS Ack Connectors" should { + "publish and consume strings through a queue" in withConnectionFactory() { connectionFactory => + val jmsSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("test") + ) + + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith(jmsSink) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withBufferSize(0).withQueue("test") + ) + + val result = jmsSource + .take(in.size) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs in + } + + "publish and consume JMS text messages with properties through a queue" in withConnectionFactory() { + connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer + .sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + + Source(msgsIn).runWith(jmsSink) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withBufferSize(0).withQueue("numbers") + ) + + val result = jmsSource + .take(msgsIn.size) + .map { env => + env.acknowledge() + env.message + } + .runWith(Sink.seq) + + // The sent message and the receiving one should have the same properties + val sortedResult = result.futureValue.sortBy(msg => msg.getIntProperty("Number")) + forAll(sortedResult.zip(msgsIn)) { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + } + } + + "publish JMS text messages with properties through a queue and consume them with a selector" in withServer() { + server => + val connectionFactory = server.createConnectionFactory + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + Source(msgsIn).runWith(jmsSink) + + val jmsSource = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withQueue("numbers") + .withSelector("IsOdd = TRUE") + ) + + val oddMsgsIn = msgsIn.filter(msg => msg.body.toInt % 2 == 1) + val result = jmsSource + .take(oddMsgsIn.size) + .map { env => + env.acknowledge(); env.message + } + .runWith(Sink.seq) + // We should have only received the odd numbers in the list + + val sortedResult = result.futureValue.sortBy(msg => msg.getIntProperty("Number")) + forAll(sortedResult.zip(oddMsgsIn)) { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + // Make sure we are only receiving odd numbers + out.getIntProperty("Number") % 2 shouldEqual 1 + } + } + + "applying backpressure when the consumer is slower than the producer" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith(JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test"))) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withBufferSize(0).withQueue("test") + ) + + val result = jmsSource + .throttle(10, 1.second, 1, ThrottleMode.shaping) + .take(in.size) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs in + } + + "disconnection should fail the stage after exhausting retries" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val result = JmsConsumer + .ackSource( + JmsConsumerSettings(system, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withQueue("test") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(3)) + ) + .runWith(Sink.seq) + Thread.sleep(500) + server.stop() + val ex = result.failed.futureValue + ex shouldBe a[ConnectionRetryException] + ex.getCause shouldBe a[JMSException] + } + + "publish and consume elements through a topic " in withConnectionFactory() { connectionFactory => + val jmsTopicSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic("topic") + ) + val jmsTopicSink2: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic("topic") + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val inNumbers = (1 to 10).map(_.toString) + + val jmsTopicSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(1).withBufferSize(0).withTopic("topic") + ) + val jmsSource2: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(1).withBufferSize(0).withTopic("topic") + ) + + import scala.concurrent.ExecutionContext.Implicits.global + + val expectedSize = in.size + inNumbers.size + val result1 = jmsTopicSource + .take(expectedSize) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + .map(_.sorted) + val result2 = jmsSource2 + .take(expectedSize) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + .map(_.sorted) + + //We wait a little to be sure that the source is connected + Thread.sleep(500) + + Source(in).runWith(jmsTopicSink) + Source(inNumbers).runWith(jmsTopicSink2) + + val expectedList: List[String] = in ++ inNumbers + result1.futureValue should contain theSameElementsAs expectedList + result2.futureValue should contain theSameElementsAs expectedList + } + + "ensure no message loss when stopping a stream" in withServer() { server => + val connectionFactory = server.createConnectionFactory + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withBufferSize(0).withQueue("numbers") + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + resultList.toSet should contain theSameElementsAs numsIn.map(_.toString) + + jmsSource.takeWithin(1.second).runWith(Sink.seq).futureValue shouldBe empty + } + + "ensure no message loss when aborting a stream" in withServer() { server => + val connectionFactory = server.createConnectionFactory + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory) + .withSessionCount(5) + .withBufferSize(0) + .withMaxPendingAcks(0) + .withQueue("numbers") + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + val ex = new Exception("Test exception") + killSwitch.abort(ex) + + import system.dispatcher + val resultTry = streamDone.map(Success(_)).recover { case e => Failure(e) }.futureValue + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + resultTry shouldBe Failure(ex) + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + resultList.toSet should contain.theSameElementsAs(numsIn.map(_.toString)) + + jmsSource.takeWithin(1.second).runWith(Sink.seq).futureValue shouldBe empty + } + + "shutdown when waiting to acknowledge messages" in withServer() { server => + val connectionFactory = server.createConnectionFactory + + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith(JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test"))) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withBufferSize(0).withQueue("test") + ) + + val (killSwitch, streamDone) = jmsSource + .toMat(Sink.ignore)(Keep.both) //no messages acknowledged + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + } + + "abort when waiting to acknowledge messages" in withServer() { server => + val connectionFactory = server.createConnectionFactory + + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith(JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test"))) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withBufferSize(0).withQueue("test") + ) + + val (killSwitch, streamDone) = jmsSource + .toMat(Sink.ignore)(Keep.both) //no messages acknowledged + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + killSwitch.abort(new Exception("aborted")) + + streamDone.failed.futureValue.getMessage shouldBe "aborted" + } + + "send acknowledgments back to the broker after max.ack.interval" in withServer() { server => + val connectionFactory = server.createConnectionFactory + + val testQueue = "test" + val aMessage = "message" + val maxAckInterval = 1.second + Source + .single(aMessage) + .runWith(JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue(testQueue))) + val source = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory) + .withMaxPendingAcks(100) + .withMaxAckInterval(maxAckInterval) + .withQueue(testQueue) + ) + + val (consumerControl, probe) = source + .map { env => + env.acknowledge() + env.message match { + case message: TextMessage => Some(message.getText) + case _ => None + } + } + .toMat(TestSink())(Keep.both) + .run() + + probe.requestNext(convertSpanToDuration(patienceConfig.timeout)) shouldBe Some(aMessage) + + eventually { + server.getQueueSize(testQueue) shouldBe 0 //queue is empty + } + + consumerControl.shutdown() + probe.expectComplete() + + // Consuming again should give us no elements, as msg was acked and therefore removed from the broker + val (emptyConsumerControl, emptySourceProbe) = source.toMat(TestSink())(Keep.both).run() + emptySourceProbe.ensureSubscription().expectNoMessage() + emptyConsumerControl.shutdown() + emptySourceProbe.expectComplete() + } + } +} diff --git a/jakarta-jms/src/test/scala/docs/scaladsl/JmsBufferedAckConnectorsSpec.scala b/jakarta-jms/src/test/scala/docs/scaladsl/JmsBufferedAckConnectorsSpec.scala new file mode 100644 index 0000000000..2c2ad71b71 --- /dev/null +++ b/jakarta-jms/src/test/scala/docs/scaladsl/JmsBufferedAckConnectorsSpec.scala @@ -0,0 +1,432 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.scaladsl + +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +import scala.annotation.tailrec +import scala.collection.immutable +import scala.collection.mutable +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.util.Failure +import scala.util.Success + +import akka.Done +import akka.stream.KillSwitches +import akka.stream.ThrottleMode +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.scaladsl.JmsConsumer +import akka.stream.alpakka.jakartajms.scaladsl.JmsConsumerControl +import akka.stream.alpakka.jakartajms.scaladsl.JmsProducer +import akka.stream.scaladsl.Flow +import akka.stream.scaladsl.Keep +import akka.stream.scaladsl.Sink +import akka.stream.scaladsl.Source +import akka.stream.testkit.scaladsl.TestSink +import jakarta.jms.JMSException +import jakarta.jms.TextMessage +import org.apache.activemq.artemis.api.jms.ActiveMQJMSConstants +import org.scalatest.Inspectors._ +import org.scalatest.time.Span.convertSpanToDuration + +class JmsBufferedAckConnectorsSpec extends JmsSpec { + + override implicit val patienceConfig: PatienceConfig = PatienceConfig(2.minutes) + + "The JMS Ack Connectors" should { + "publish and consume strings through a queue" in withConnectionFactory() { connectionFactory => + val queueName = createName("test") + val jmsSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith(jmsSink) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val result = jmsSource + .take(in.size) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs in + } + + "publish and consume JMS text messages with properties through a queue" in withConnectionFactory() { + connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + + Source(msgsIn).runWith(jmsSink) + + //#source + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + ) + + val result: Future[immutable.Seq[jakarta.jms.Message]] = + jmsSource + .take(msgsIn.size) + .map { ackEnvelope => + ackEnvelope.acknowledge() + ackEnvelope.message + } + .runWith(Sink.seq) + //#source + + // The sent message and the receiving one should have the same properties + val sortedResult = result.futureValue.sortBy(msg => msg.getIntProperty("Number")) + forAll(sortedResult.zip(msgsIn)) { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + } + } + + "publish JMS text messages with properties through a queue and consume them with a selector" in withServer() { + server => + val connectionFactory = server.createConnectionFactory + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + Source(msgsIn).runWith(jmsSink) + + val jmsSource = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue("numbers") + .withSelector("IsOdd = TRUE") + ) + + val oddMsgsIn = msgsIn.filter(msg => msg.body.toInt % 2 == 1) + val result = jmsSource + .take(oddMsgsIn.size) + .map { env => + env.acknowledge() + env.message + } + .runWith(Sink.seq) + // We should have only received the odd numbers in the list + + val sortedResult = result.futureValue.sortBy(msg => msg.getIntProperty("Number")) + forAll(sortedResult.zip(oddMsgsIn)) { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + // Make sure we are only receiving odd numbers + out.getIntProperty("Number") % 2 shouldEqual 1 + } + } + + "applying backpressure when the consumer is slower than the producer" in withConnectionFactory() { + connectionFactory => + val queueName = createName("test") + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith( + JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName)) + ) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val result = jmsSource + .throttle(10, 1.second, 1, ThrottleMode.shaping) + .take(in.size) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs in + } + + "disconnection should fail the stage after exhausting retries" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val result = JmsConsumer + .ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(3)) + ) + .runWith(Sink.seq) + Thread.sleep(500) + server.stop() + val ex = result.failed.futureValue + ex shouldBe a[ConnectionRetryException] + ex.getCause shouldBe a[JMSException] + } + + "publish and consume elements through a topic " in withConnectionFactory() { connectionFactory => + import system.dispatcher + + val topicName = createName("topic") + val jmsTopicSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic(topicName) + ) + val jmsTopicSink2: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic(topicName) + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val inNumbers = (1 to 10).map(_.toString) + + val jmsTopicSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(1).withTopic(topicName) + ) + val jmsSource2: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(1).withTopic(topicName) + ) + + val expectedSize = in.size + inNumbers.size + val result1 = jmsTopicSource + .take(expectedSize) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + .map(_.sorted) + val result2 = jmsSource2 + .take(expectedSize) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.acknowledge(); text } + .runWith(Sink.seq) + .map(_.sorted) + + //We wait a little to be sure that the source is connected + Thread.sleep(500) + + Source(in).runWith(jmsTopicSink) + Source(inNumbers).runWith(jmsTopicSink2) + + val expectedList: List[String] = in ++ inNumbers + result1.futureValue should contain theSameElementsAs expectedList + result2.futureValue should contain theSameElementsAs expectedList + } + + "ensure no message loss when stopping a stream" in withConnectionFactory() { connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime + Thread.sleep(2000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + resultQueue.size should be < numsIn.size + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + resultList.toSet should contain.theSameElementsAs(numsIn.map(_.toString)) + } + + "ensure no message loss when aborting a stream" in withConnectionFactory() { connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + // We need this ack mode for AMQ to not lose messages as ack normally acks any messages read on the session. + val individualAck = new AcknowledgeMode(ActiveMQJMSConstants.INDIVIDUAL_ACKNOWLEDGE) + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + .withAcknowledgeMode(individualAck) + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + val ex = new Exception("Test exception") + killSwitch.abort(ex) + + import system.dispatcher + val resultTry = streamDone.map(Success(_)).recover { case e => Failure(e) }.futureValue + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + resultTry shouldBe Failure(ex) + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + resultList.toSet should contain.theSameElementsAs(numsIn.map(_.toString)) + } + + "send acknowledgments back to the broker after max.ack.interval" in withServer() { server => + val connectionFactory = server.createTopicConnectionFactory + val testQueue = "test" + val aMessage = "message" + val maxAckInterval = 1.second + Source + .single(aMessage) + .runWith(JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue(testQueue))) + val source = JmsConsumer.ackSource( + JmsConsumerSettings(system, connectionFactory) + .withMaxPendingAcks(100) + .withMaxAckInterval(maxAckInterval) + .withQueue(testQueue) + ) + + val (consumerControl, probe) = source + .map { env => + env.acknowledge() + env.message match { + case message: TextMessage => Some(message.getText) + case _ => None + + } + } + .toMat(TestSink())(Keep.both) + .run() + + probe.requestNext(convertSpanToDuration(patienceConfig.timeout)) shouldBe Some(aMessage) + + eventually { + server.getQueueSize(testQueue) shouldBe 0 + } + + consumerControl.shutdown() + probe.expectComplete() + + // Consuming again should give us no elements, as msg was acked and therefore removed from the broker + val (emptyConsumerControl, emptySourceProbe) = source.toMat(TestSink())(Keep.both).run() + emptySourceProbe.ensureSubscription().expectNoMessage() + emptyConsumerControl.shutdown() + emptySourceProbe.expectComplete() + } + } +} diff --git a/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala b/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala new file mode 100644 index 0000000000..b05e9d50a5 --- /dev/null +++ b/jakarta-jms/src/test/scala/docs/scaladsl/JmsConnectorsSpec.scala @@ -0,0 +1,1273 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.scaladsl + +import java.nio.charset.Charset +import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.{CountDownLatch, LinkedBlockingQueue, ThreadLocalRandom, TimeUnit} + +import akka.stream._ +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.scaladsl._ +import akka.stream.scaladsl.{Flow, Keep, Sink, Source} +import akka.{Done, NotUsed} +import jakarta.jms._ + +import org.apache.activemq.artemis.jms.client.ActiveMQQueue +import org.apache.activemq.artemis.jms.client.{ActiveMQConnectionFactory, ActiveMQSession} +import org.mockito.ArgumentMatchers._ +import org.mockito.Mockito._ +import org.mockito.invocation.InvocationOnMock +import org.mockito.stubbing.Answer + +import scala.annotation.tailrec +import scala.collection.immutable +import scala.collection.mutable +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +final case class DummyObject(payload: String) + +class JmsConnectorsSpec extends JmsSpec { + + override implicit val patienceConfig: PatienceConfig = PatienceConfig(2.minutes) + + private def assertClosed(connectionFactory: CachedConnectionFactory): Unit = { + intercept[JMSException] { + connectionFactory.cachedConnection.getClientID + }.getMessage shouldBe "Connection is closed" + } + + "The JMS Connectors" should { + "publish and consume strings through a queue" in withServer() { server => + val url = server.brokerUri + //#connection-factory + //#text-sink + //#text-source + val connectionFactory: jakarta.jms.ConnectionFactory = + new org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory(url) + //#connection-factory + //#text-sink + //#text-source + + //#text-sink + + val jmsSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val streamCompletion: Future[Done] = + Source(in) + .runWith(jmsSink) + //#text-sink + + //#text-source + val jmsSource: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(system, connectionFactory).withQueue("test") + ) + + val result: Future[immutable.Seq[String]] = jmsSource.take(in.size).runWith(Sink.seq) + //#text-source + + streamCompletion.futureValue shouldEqual Done + result.futureValue shouldEqual in + } + + "publish and consume serializable objects through a queue" in withConnectionFactory() { connFactory => + //#object-sink + //#object-source + val connectionFactory = connFactory.asInstanceOf[ActiveMQConnectionFactory] + connectionFactory.setDeserializationWhiteList(classOf[DummyObject].getPackage.getName) + //#object-sink + //#object-source + + //#object-sink + + val jmsSink: Sink[Serializable, Future[Done]] = JmsProducer.objectSink( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + val in = DummyObject("ThisIsATest") + val streamCompletion: Future[Done] = + Source + .single(in) + .runWith(jmsSink) + //#object-sink + + //#object-source + val jmsSource: Source[java.io.Serializable, JmsConsumerControl] = JmsConsumer.objectSource( + JmsConsumerSettings(system, connectionFactory).withQueue("test") + ) + + val result: Future[java.io.Serializable] = + jmsSource + .take(1) + .runWith(Sink.head) + //#object-source + + streamCompletion.futureValue shouldEqual Done + result.futureValue shouldEqual in + } + + "publish and consume bytearray through a queue" in withConnectionFactory() { connectionFactory => + //#bytearray-sink + val jmsSink: Sink[Array[Byte], Future[Done]] = JmsProducer.bytesSink( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + val in: Array[Byte] = "ThisIsATest".getBytes(Charset.forName("UTF-8")) + val streamCompletion: Future[Done] = + Source + .single(in) + .runWith(jmsSink) + //#bytearray-sink + + //#bytearray-source + val jmsSource: Source[Array[Byte], JmsConsumerControl] = JmsConsumer.bytesSource( + JmsConsumerSettings(system, connectionFactory).withQueue("test") + ) + + val result: Future[Array[Byte]] = + jmsSource + .take(1) + .runWith(Sink.head) + //#bytearray-source + + streamCompletion.futureValue shouldEqual Done + result.futureValue shouldEqual in + } + + "publish and consume map through a queue" in withConnectionFactory() { connectionFactory => + //#map-sink + val jmsSink: Sink[Map[String, Any], Future[Done]] = JmsProducer.mapSink( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + + val input = List( + Map[String, Any]( + "string" -> "value", + "int value" -> 42, + "double value" -> 43.toDouble, + "short value" -> 7.toShort, + "boolean value" -> true, + "long value" -> 7.toLong, + "bytearray" -> "AStringAsByteArray".getBytes(Charset.forName("UTF-8")), + "byte" -> 1.toByte + ) + ) + + val streamCompletion: Future[Done] = + Source(input) + .runWith(jmsSink) + //#map-sink + + //#map-source + val jmsSource: Source[Map[String, Any], JmsConsumerControl] = JmsConsumer.mapSource( + JmsConsumerSettings(system, connectionFactory).withQueue("test") + ) + + val result: Future[immutable.Seq[Map[String, Any]]] = + jmsSource + .take(1) + .runWith(Sink.seq) + //#map-source + + streamCompletion.futureValue shouldEqual Done + result.futureValue.zip(input).foreach { + case (out, in) => + out("string") shouldEqual in("string") + out("int value") shouldEqual in("int value") + out("double value") shouldEqual in("double value") + out("short value") shouldEqual in("short value") + out("boolean value") shouldEqual in("boolean value") + out("long value") shouldEqual in("long value") + out("byte") shouldEqual in("byte") + + val outBytes = out("bytearray").asInstanceOf[Array[Byte]] + new String(outBytes, Charset.forName("UTF-8")) shouldBe "AStringAsByteArray" + } + } + + "publish and consume JMS text messages with properties through a queue" in withConnectionFactory() { + connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + //#create-messages-with-properties + val msgsIn = (1 to 10).toList.map { n => + akka.stream.alpakka.jakartajms + .JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + //#create-messages-with-properties + + Source(msgsIn).runWith(jmsSink) + + //#jms-source + val jmsSource: Source[jakarta.jms.Message, JmsConsumerControl] = JmsConsumer( + JmsConsumerSettings(system, connectionFactory).withQueue("numbers") + ) + + val (control, result): (JmsConsumerControl, Future[immutable.Seq[String]]) = + jmsSource + .take(msgsIn.size) + .map { + case t: jakarta.jms.TextMessage => t.getText + case other => sys.error(s"unexpected message type ${other.getClass}") + } + .toMat(Sink.seq)(Keep.both) + .run() + //#jms-source + + result.futureValue should have size 10 + //#jms-source + + control.shutdown() + //#jms-source + } + + "publish and consume JMS text messages" in withConnectionFactory() { connectionFactory => + //#create-jms-sink + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val finished: Future[Done] = + Source(immutable.Seq("Message A", "Message B")) + .map(JmsTextMessage(_)) + .runWith(jmsSink) + //#create-jms-sink + + val jmsSource: Source[Message, JmsConsumerControl] = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory).withQueue("numbers") + ) + + val result: Future[Seq[Message]] = jmsSource.take(2).runWith(Sink.seq) + + finished.futureValue shouldBe Done + + result.futureValue.zip(immutable.Seq("Message A", "Message B")).foreach { + case (out, in) => + out.asInstanceOf[TextMessage].getText shouldEqual in + } + } + + "publish and consume JMS text messages with header through a queue" in withConnectionFactory() { + connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + //#create-messages-with-headers + val msgsIn = (1 to 10).toList.map { n => + JmsTextMessage(n.toString) + .withHeader(JmsType("type")) + .withHeader(JmsCorrelationId("correlationId")) + .withHeader(JmsReplyTo.queue("test-reply")) + .withHeader(JmsTimeToLive(FiniteDuration(999, TimeUnit.SECONDS))) + .withHeader(JmsPriority(2)) + .withHeader(JmsDeliveryMode(DeliveryMode.NON_PERSISTENT)) + } + //#create-messages-with-headers + + Source(msgsIn).runWith(jmsSink) + + val jmsSource: Source[Message, JmsConsumerControl] = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory).withQueue("numbers") + ) + + val result: Future[Seq[Message]] = jmsSource.take(msgsIn.size).runWith(Sink.seq) + + // The sent message and the receiving one should have the same properties + result.futureValue.foreach { outMsg => + outMsg.getJMSType shouldBe "type" + outMsg.getJMSCorrelationID shouldBe "correlationId" + outMsg.getJMSReplyTo.asInstanceOf[ActiveMQQueue].getQueueName shouldBe "test-reply" + outMsg.getJMSExpiration should not be 0 + outMsg.getJMSPriority shouldBe 2 + outMsg.getJMSDeliveryMode shouldBe DeliveryMode.NON_PERSISTENT + } + } + + "publish and consume JMS text messages through a queue with custom queue creator " in withConnectionFactory() { + connectionFactory => + //#custom-destination + def createQueue(destinationName: String): Session => jakarta.jms.Queue = { (session: Session) => + val amqSession = session.asInstanceOf[ActiveMQSession] + amqSession.createQueue(s"my-$destinationName") + } + //#custom-destination + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory) + .withDestination(CustomDestination("custom", createQueue("custom"))) + ) + + val msgsIn: immutable.Seq[JmsTextMessage] = (1 to 10).toList.map { n => + JmsTextMessage(n.toString) + } + + Source(msgsIn).runWith(jmsSink) + + //#custom-destination + + val jmsSource: Source[jakarta.jms.Message, JmsConsumerControl] = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withDestination(CustomDestination("custom", createQueue("custom"))) + ) + //#custom-destination + + val result: Future[immutable.Seq[jakarta.jms.Message]] = jmsSource.take(msgsIn.size).runWith(Sink.seq) + + // The sent message and the receiving one should have the same properties + result.futureValue.zip(msgsIn).foreach { + case (out, in) => + out.asInstanceOf[TextMessage].getText shouldEqual in.body + } + } + + "publish JMS text messages with properties through a queue and consume them with a selector" in withConnectionFactory() { + connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val msgsIn = (1 to 10).toList.map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + Source(msgsIn).runWith(jmsSink) + + //#source-with-selector + val jmsSource = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue("numbers") + .withSelector("IsOdd = TRUE") + ) + //#source-with-selector + + val oddMsgsIn = msgsIn.filter(msg => msg.body.toInt % 2 == 1) + val result = jmsSource.take(oddMsgsIn.size).runWith(Sink.seq) + + // We should have only received the odd numbers in the list + result.futureValue.zip(oddMsgsIn).foreach { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + // Make sure we are only receiving odd numbers + out.getIntProperty("Number") % 2 shouldEqual 1 + } + } + + "applying backpressure when the consumer is slower than the producer" in withConnectionFactory() { + connectionFactory => + val in = List("a", "b", "c") + Source(in).runWith( + JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test")) + ) + + val result = JmsConsumer + .textSource(JmsConsumerSettings(consumerConfig, connectionFactory).withBufferSize(1).withQueue("test")) + .throttle(1, 1.second, 1, ThrottleMode.shaping) + .take(in.size) + .runWith(Sink.seq) + + result.futureValue shouldEqual in + } + + "disconnection should fail the stage after exhausting retries" in withServer() { server => + val connectionFactory = server.createConnectionFactory + val result = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue("test") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(3)) + ).runWith(Sink.seq) + Thread.sleep(500) + server.stop() + val ex = result.failed.futureValue + ex shouldBe a[ConnectionRetryException] + ex.getCause shouldBe a[JMSException] + } + + "publish and consume elements through a topic with custom topic creator" in withConnectionFactory() { + connectionFactory => + def createTopic(destinationName: String): Session => jakarta.jms.Topic = { (session: Session) => + val amqSession = session.asInstanceOf[ActiveMQSession] + amqSession.createTopic(s"my-$destinationName") + } + + //#create-custom-jms-topic-sink + val jmsTopicSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory) + .withDestination(CustomDestination("topic", createTopic("topic"))) + ) + //#create-custom-jms-topic-sink + val jmsTopicSink2: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory) + .withDestination(CustomDestination("topic", createTopic("topic"))) + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val inNumbers = (1 to 10).map(_.toString) + + //#create-custom-jms-topic-source + val jmsTopicSource: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withDestination(CustomDestination("topic", createTopic("topic"))) + ) + //#create-custom-jms-topic-source + val jmsSource2: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withDestination(CustomDestination("topic", createTopic("topic"))) + ) + + val expectedSize = in.size + inNumbers.size + + import scala.concurrent.ExecutionContext.Implicits.global + + val result1 = jmsTopicSource.take(expectedSize).runWith(Sink.seq).map(_.sorted) + val result2 = jmsSource2.take(expectedSize).runWith(Sink.seq).map(_.sorted) + + //We wait a little to be sure that the source is connected + Thread.sleep(500) + + Source(in).runWith(jmsTopicSink) + + Source(inNumbers).runWith(jmsTopicSink2) + + val expectedList: List[String] = in ++ inNumbers + result1.futureValue shouldEqual expectedList.sorted + result2.futureValue shouldEqual expectedList.sorted + } + + "publish and consume elements through a topic " in withConnectionFactory() { connectionFactory => + import system.dispatcher + + //#create-topic-sink + val jmsTopicSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic("topic") + ) + //#create-topic-sink + val jmsTopicSink2: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic("topic") + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val inNumbers = (1 to 10).map(_.toString) + + //#create-topic-source + val jmsTopicSource: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withTopic("topic") + ) + //#create-topic-source + val jmsSource2: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withTopic("topic") + ) + + val expectedSize = in.size + inNumbers.size + //#run-topic-source + val result1 = jmsTopicSource.take(expectedSize).runWith(Sink.seq).map(_.sorted) + val result2 = jmsSource2.take(expectedSize).runWith(Sink.seq).map(_.sorted) + //#run-topic-source + + //We wait a little to be sure that the source is connected + Thread.sleep(500) + + //#run-topic-sink + Source(in).runWith(jmsTopicSink) + //#run-topic-sink + Source(inNumbers).runWith(jmsTopicSink2) + + val expectedList: List[String] = in ++ inNumbers + result1.futureValue shouldEqual expectedList.sorted + result2.futureValue shouldEqual expectedList.sorted + } + + "publish and consume JMS text messages through a queue without acknowledgingg them" in withConnectionFactory() { + connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val msgsIn = (1 to 10).toList.map { n => + JmsTextMessage(n.toString) + } + + Source(msgsIn).runWith(jmsSink) + + val jmsSource: Source[Message, JmsConsumerControl] = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue("numbers") + .withAcknowledgeMode(AcknowledgeMode.ClientAcknowledge) + ) + + val result = jmsSource + .take(msgsIn.size) + .map { + case textMessage: TextMessage => + textMessage.getText + case other => fail(s"didn't match `$other`") + } + .runWith(Sink.seq) + + result.futureValue shouldEqual msgsIn.map(_.body) + + // messages were not acknowledged, may be delivered again + jmsSource + .takeWithin(5.seconds) + .runWith(Sink.seq) + .futureValue should not be empty + } + + "sink successful completion" in withConnectionFactory() { connFactory => + val connectionFactory = new CachedConnectionFactory(connFactory) + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val msgsIn = (1 to 10).toList.map { n => + JmsTextMessage(n.toString) + } + + val completionFuture: Future[Done] = Source(msgsIn).runWith(jmsSink) + completionFuture.futureValue shouldBe Done + // make sure connection was closed + eventually { + assertClosed(connectionFactory) + } + } + + "sink exceptional completion" in withConnectionFactory() { connFactory => + val connectionFactory = new CachedConnectionFactory(connFactory) + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("numbers") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(0)) + ) + + val completionFuture: Future[Done] = Source + .failed[JmsTextMessage](new RuntimeException("Simulated error")) + .runWith(jmsSink) + + completionFuture.failed.futureValue shouldBe a[RuntimeException] + // make sure connection was closed + eventually { assertClosed(connectionFactory) } + } + + "producer disconnect exceptional completion" in withServer() { server => + import system.dispatcher + + val connectionFactory = new CachedConnectionFactory(server.createConnectionFactory) + + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory) + .withQueue("numbers") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(2)) + ) + + val completionFuture: Future[Done] = Source(0 to 10) + .mapAsync(1)( + n => + Future { + Thread.sleep(100) + JmsTextMessage(n.toString) + } + ) + .runWith(jmsSink) + + server.stop() + + val exception = completionFuture.failed.futureValue + exception shouldBe a[ConnectionRetryException] + exception.getCause shouldBe a[JMSException] + + // connection should be either + // - not yet initialized before broker stop, or + // - closed on broker stop (if preStart came first). + if (connectionFactory.cachedConnection != null) { + assertClosed(connectionFactory) + } + } + + "ensure no message loss when stopping a stream" in withConnectionFactory() { connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[Message, JmsConsumerControl] = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(5) + .withQueue("numbers") + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat(Sink.foreach(msg => resultQueue.add(msg.asInstanceOf[TextMessage].getText)))(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + + val killSwitch2 = jmsSource + .to(Sink.foreach(msg => resultQueue.add(msg.asInstanceOf[TextMessage].getText))) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + resultList.sortBy(_.toInt) should contain theSameElementsAs numsIn.map(_.toString) + } + + "lose some elements when aborting a stream" in withConnectionFactory() { connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("numbers") + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[AckEnvelope, JmsConsumerControl] = JmsConsumer.ackSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withBufferSize(5) + .withQueue("numbers") + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + val ex = new Exception("Test exception") + killSwitch.abort(ex) + + import system.dispatcher + val resultTry = streamDone.map(Success(_)).recover { case e => Failure(e) }.futureValue + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + resultTry shouldBe Failure(ex) + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.acknowledge() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + // We may have lost some messages here, but most of them should have arrived. + resultList.size should be > (numsIn.size / 2) + resultList.size should be < numsIn.size + resultList.size shouldBe resultList.toSet.size // no duplicates + } + + "only fail after maxBackoff retry" in withServer() { server => + val connectionFactory = server.createConnectionFactory + server.stop() + val startTime = System.currentTimeMillis + val result = JmsConsumer( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(4)) + .withQueue("test") + ).runWith(Sink.seq) + + val ex = result.failed.futureValue + val endTime = System.currentTimeMillis + + (endTime - startTime) shouldBe >(100L + 400L + 900L + 1600L) + ex shouldBe a[ConnectionRetryException] + ex.getCause shouldBe a[JMSException] + } + + "browse" in withConnectionFactory() { connectionFactory => + val in = List(1 to 100).map(_.toString()) + + withClue("write some messages") { + Source(in) + .runWith(JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue("test"))) + .futureValue + } + + withClue("browse the messages") { + //#browse-source + + val browseSource: Source[jakarta.jms.Message, NotUsed] = JmsConsumer.browse( + JmsBrowseSettings(system, connectionFactory) + .withQueue("test") + ) + + val result: Future[immutable.Seq[jakarta.jms.Message]] = + browseSource.runWith(Sink.seq) + //#browse-source + + result.futureValue.collect { case msg: TextMessage => msg.getText } shouldEqual in + } + + withClue("browse the messages again") { + // the messages should not have been consumed + val result = JmsConsumer + .browse(JmsBrowseSettings(browseConfig, connectionFactory).withQueue("test")) + .collect { case msg: TextMessage => msg.getText } + .runWith(Sink.seq) + + result.futureValue shouldEqual in + } + } + + "producer flow" in withConnectionFactory() { connectionFactory => + //#flow-producer + + val flow: Flow[JmsMessage, JmsMessage, JmsProducerStatus] = + JmsProducer.flow( + JmsProducerSettings(system, connectionFactory) + .withQueue("test") + ) + + val input: immutable.Seq[JmsTextMessage] = + (1 to 100).map(i => JmsTextMessage(i.toString)) + + val result: Future[Seq[JmsMessage]] = Source(input) + .via(flow) + .runWith(Sink.seq) + //#flow-producer + + result.futureValue should ===(input) + } + + "accept message-defined destinations" in withConnectionFactory() { connectionFactory => + //#run-directed-flow-producer + val flowSink: Flow[JmsMessage, JmsMessage, JmsProducerStatus] = + JmsProducer.flow( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + + val input = (1 to 100).map { i => + val queueName = if (i % 2 == 0) "even" else "odd" + JmsTextMessage(i.toString).toQueue(queueName) + } + Source(input).via(flowSink).runWith(Sink.ignore) + //#run-directed-flow-producer + + val jmsEvenSource: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withQueue("even") + ) + val jmsOddSource: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withQueue("odd") + ) + + jmsEvenSource.take(input.size / 2).map(_.toInt).runWith(Sink.seq).futureValue shouldBe (2 to 100 by 2) + jmsOddSource.take(input.size / 2).map(_.toInt).runWith(Sink.seq).futureValue shouldBe (1 to 99 by 2) + } + + "fail if message destination is not defined" in { + val connectionFactory = new ActiveMQConnectionFactory("vm://13") + + an[IllegalArgumentException] shouldBe thrownBy { + JmsProducer.flow(JmsProducerSettings(producerConfig, connectionFactory)) + } + } + + "publish and consume strings through a queue with multiple sessions" in withConnectionFactory() { + connectionFactory => + val jmsSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("test").withSessionCount(5) + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val sinkOut = Source(in).runWith(jmsSink) + + val jmsSource: Source[String, JmsConsumerControl] = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue("test") + ) + + val result = jmsSource.take(in.size).runWith(Sink.seq) + + sinkOut.futureValue shouldBe Done + result.futureValue should contain allElementsOf in + } + + "produce elements in order" in withMockedProducer { ctx => + import ctx._ + val delays = new AtomicInteger() + val textMessage = mock(classOf[TextMessage]) + + val delayedSend = new Answer[Unit] { + override def answer(invocation: InvocationOnMock): Unit = { + delays.incrementAndGet() + Thread.sleep(ThreadLocalRandom.current().nextInt(1, 10)) + } + } + + when(session.createTextMessage(anyString())).thenReturn(textMessage) + when(producer.send(any[jakarta.jms.Destination], any[Message], anyInt(), anyInt(), anyLong())) + .thenAnswer(delayedSend) + + val in = (1 to 50).map(i => JmsTextMessage(i.toString)) + val jmsFlow = JmsProducer.flow[JmsTextMessage]( + JmsProducerSettings(producerConfig, factory).withQueue("test").withSessionCount(8) + ) + + val result = Source(in).via(jmsFlow).toMat(Sink.seq)(Keep.right).run() + + result.futureValue shouldEqual in + delays.get shouldBe 50 + } + + "fail fast on the first failing send" in withMockedProducer { ctx => + import ctx._ + val sendLatch = new CountDownLatch(3) + val receiveLatch = new CountDownLatch(3) + + val messages = (1 to 10).map(i => mock(classOf[TextMessage]) -> i).toMap + messages.foreach { + case (msg, i) => when(session.createTextMessage(i.toString)).thenReturn(msg) + } + + val failOnFifthAndDelayFourthItem = new Answer[Unit] { + override def answer(invocation: InvocationOnMock): Unit = { + val msgNo = messages(invocation.getArgument[TextMessage](1)) + msgNo match { + case 1 | 2 | 3 => + sendLatch.countDown() // first three sends work... + case 4 => + Thread.sleep(30000) // this one gets delayed... + case 5 => + sendLatch.await() + receiveLatch.await() + throw new RuntimeException("Mocked send failure") // this one fails. + case _ => () + } + } + } + when(producer.send(any[jakarta.jms.Destination], any[Message], anyInt(), anyInt(), anyLong())) + .thenAnswer(failOnFifthAndDelayFourthItem) + + val in = (1 to 10).map(i => JmsTextMessage(i.toString)) + val done = JmsTextMessage("done") + val jmsFlow = JmsProducer.flow[JmsTextMessage]( + JmsProducerSettings(producerConfig, factory).withQueue("test").withSessionCount(8) + ) + val result = Source(in) + .via(jmsFlow) + .alsoTo(Sink.foreach(_ => receiveLatch.countDown())) + .recover { case _ => done } + .toMat(Sink.seq)(Keep.right) + .run() + + // expect send failure on no 5. to cause immediate stream failure (after no. 1, 2 and 3), + // even though no 4. is still in-flight. + result.futureValue shouldEqual in.take(3) :+ done + } + + "put back JmsProducer to the pool when send fails" in withMockedProducer { ctx => + import ctx._ + + val messages = (1 to 10).map(i => mock(classOf[TextMessage]) -> i).toMap + messages.foreach { + case (msg, i) => when(session.createTextMessage(i.toString)).thenReturn(msg) + } + + val failOnEvenCount = new Answer[Unit] { + override def answer(invocation: InvocationOnMock): Unit = { + val msgNo = messages(invocation.getArgument[TextMessage](1)) + if (msgNo % 2 == 0) throw new RuntimeException("Mocked send failure") + } + } + + when(producer.send(any[jakarta.jms.Destination], any[Message], anyInt(), anyInt(), anyLong())) + .thenAnswer(failOnEvenCount) + + val decider: Supervision.Decider = { + case _: RuntimeException => Supervision.Resume + case _ => Supervision.Stop + } + val jmsFlow = JmsProducer + .flow[JmsTextMessage](JmsProducerSettings(producerConfig, factory).withQueue("test").withSessionCount(2)) + .withAttributes(ActorAttributes.supervisionStrategy(decider)) + + val in = (1 to 10).map(i => JmsTextMessage(i.toString)) + val result = Source(in).via(jmsFlow).toMat(Sink.seq)(Keep.right).run() + + result.futureValue.map(_.body.toInt) shouldEqual Seq(1, 3, 5, 7, 9) + } + + val mockMessages = (0 until 10).map(_.toString) + + val mockMessageGenerator = new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = { + val listener = invocation.getArgument[MessageListener](0) + mockMessages.foreach { s => + val message = mock(classOf[TextMessage]) + when(message.getText).thenReturn(s) + listener.onMessage(message) + } + } + } + + "reconnect when timing out establishing a connection" in { + val factory = mock(classOf[ConnectionFactory]) + val connection = mock(classOf[Connection]) + val session = mock(classOf[Session]) + val consumer = mock(classOf[MessageConsumer]) + @volatile var connectCount = 0 + val connectTimeout = 2.seconds + val connectDelay = 10.seconds + + when(factory.createConnection()).thenAnswer(new Answer[Connection]() { + override def answer(invocation: InvocationOnMock): Connection = { + connectCount += 1 + if (connectCount == 1) Thread.sleep(connectDelay.toMillis) // Cause a connect timeout + connection + } + }) + + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + when(session.createConsumer(any[jakarta.jms.Destination])).thenReturn(consumer) + when(consumer.setMessageListener(any[MessageListener])).thenAnswer(mockMessageGenerator) + + val jmsSource = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, factory) + .withQueue("test") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withConnectTimeout(connectTimeout)) + ) + + val startTime = System.nanoTime + val resultFuture = jmsSource.take(10).runWith(Sink.seq) + val result = resultFuture.futureValue + val timeTaken = Duration.fromNanos(System.nanoTime - startTime) + + result should contain theSameElementsAs mockMessages + connectCount shouldBe 2 + timeTaken should be > connectTimeout + timeTaken should be < connectDelay + } + + "reconnect when timing out starting a connection" in { + val factory = mock(classOf[ConnectionFactory]) + val connection = mock(classOf[Connection]) + val session = mock(classOf[Session]) + val consumer = mock(classOf[MessageConsumer]) + @volatile var connectStartCount = 0 + val connectTimeout = 2.seconds + val connectStartDelay = 10.seconds + + when(factory.createConnection()).thenReturn(connection) + + when(connection.start()).thenAnswer(new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = { + connectStartCount += 1 + if (connectStartCount == 1) Thread.sleep(connectStartDelay.toMillis) // first connection start to timeout + } + }) + + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + when(session.createConsumer(any[jakarta.jms.Destination])).thenReturn(consumer) + when(consumer.setMessageListener(any[MessageListener])).thenAnswer(mockMessageGenerator) + + val jmsSource = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, factory) + .withQueue("test") + .withConnectionRetrySettings(ConnectionRetrySettings(system).withConnectTimeout(connectTimeout)) + ) + + val startTime = System.nanoTime + val resultFuture = jmsSource.take(10).runWith(Sink.seq) + val result = resultFuture.futureValue + val timeTaken = Duration.fromNanos(System.nanoTime - startTime) + + result should contain theSameElementsAs mockMessages + connectStartCount shouldBe 2 + timeTaken should be > connectTimeout + timeTaken should be < connectStartDelay + } + + "reconnect when runtime connection exception occurs" in { + val factory = mock(classOf[ConnectionFactory]) + val connection = mock(classOf[Connection]) + val session = mock(classOf[Session]) + val consumer = mock(classOf[MessageConsumer]) + @volatile var connectCount = 0 + @volatile var exceptionListener: Option[ExceptionListener] = None + val messageGroups = mockMessages.grouped(3) + + when(factory.createConnection()).thenAnswer(new Answer[Connection]() { + override def answer(invocation: InvocationOnMock): Connection = { + connectCount += 1 + connection + } + }) + + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + + when(connection.setExceptionListener(any[ExceptionListener])).thenAnswer(new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = + exceptionListener = Option(invocation.getArgument[ExceptionListener](0)) + }) + + when(session.createConsumer(any[jakarta.jms.Destination])).thenReturn(consumer) + + when(consumer.setMessageListener(any[MessageListener])).thenAnswer(new Answer[Unit]() { + override def answer(invocation: InvocationOnMock): Unit = { + val listener = invocation.getArgument[MessageListener](0) + val thisMessageGroup = messageGroups.next() + thisMessageGroup.foreach { s => + val message = mock(classOf[TextMessage]) + when(message.getText).thenReturn(s) + listener.onMessage(message) + } + if (messageGroups.hasNext) { + exceptionListener.foreach(_.onException(new JMSException("Mock: causing an exception while consuming"))) + } + } + }) + + val jmsSource = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, factory) + .withQueue("test") + ) + + val resultFuture = jmsSource.take(mockMessages.size).runWith(Sink.seq) + + resultFuture.futureValue should contain theSameElementsAs mockMessages + + // Connects 1 time at start and 3 times each 3 messages. + connectCount shouldBe 4 + } + + "pass through message envelopes" in withConnectionFactory() { connectionFactory => + //#run-flexi-flow-producer + val jmsProducer: Flow[JmsEnvelope[String], JmsEnvelope[String], JmsProducerStatus] = + JmsProducer.flexiFlow[String]( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + + val data = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val in: immutable.Seq[JmsTextMessagePassThrough[String]] = + data.map(t => JmsTextMessage(t).withPassThrough(t)) + + val result = Source(in) + .via(jmsProducer) + .map(_.passThrough) // extract the value passed through + .runWith(Sink.seq) + //#run-flexi-flow-producer + + result.futureValue shouldEqual data + + val sentData = + JmsConsumer + .textSource(JmsConsumerSettings(consumerConfig, connectionFactory).withQueue("test")) + .take(data.size) + .runWith(Sink.seq) + + sentData.futureValue shouldEqual data + } + + "pass through empty envelopes" in { + val connectionFactory = mock(classOf[ConnectionFactory]) + val connection = mock(classOf[Connection]) + val session = mock(classOf[Session]) + val producer = mock(classOf[MessageProducer]) + val message = mock(classOf[TextMessage]) + + when(connectionFactory.createConnection()).thenReturn(connection) + when(connection.createSession(anyBoolean(), anyInt())).thenReturn(session) + when(session.createProducer(any[jakarta.jms.Destination])).thenReturn(producer) + when(session.createTextMessage(any[String])).thenReturn(message) + + //#run-flexi-flow-pass-through-producer + val jmsProducer = JmsProducer.flexiFlow[String]( + JmsProducerSettings(producerConfig, connectionFactory).withQueue("topic") + ) + + val data = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val in = data.map(t => JmsPassThrough(t)) + + val result = Source(in).via(jmsProducer).map(_.passThrough).runWith(Sink.seq) + //#run-flexi-flow-pass-through-producer + + result.futureValue shouldEqual data + + verify(session, never()).createTextMessage(any[String]) + verify(producer, never()).send(any[jakarta.jms.Destination], any[Message], any[Int], any[Int], any[Long]) + } + } + + "publish and subscribe with a durable subscription" in withServer() { server => + import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory + val producerConnectionFactory = server.createConnectionFactory + //#create-connection-factory-with-client-id + val consumerConnectionFactory = server.createConnectionFactory.asInstanceOf[ActiveMQConnectionFactory] + consumerConnectionFactory.setClientID(getClass.getSimpleName) + //#create-connection-factory-with-client-id + + val jmsTopicSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, producerConnectionFactory).withTopic("topic") + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + + //#create-durable-topic-source + val jmsTopicSource = JmsConsumer.textSource( + JmsConsumerSettings(consumerConfig, consumerConnectionFactory) + .withDurableTopic("topic", "durable-test") + ) + //#create-durable-topic-source + + //#run-durable-topic-source + val result = jmsTopicSource.take(in.size).runWith(Sink.seq) + //#run-durable-topic-source + + // We wait a little to be sure that the source is connected + Thread.sleep(500) + + Source(in).runWith(jmsTopicSink) + + result.futureValue shouldEqual in + } + + "support request/reply with temporary queues" in withConnectionFactory() { connectionFactory => + val connection = connectionFactory.createConnection() + connection.start() + val session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE) + val tempQueue = session.createTemporaryQueue() + val tempQueueDest = alpakka.jakartajms.Destination(tempQueue) + val message = "ThisIsATest" + val correlationId = UUID.randomUUID().toString + + val toRespondSink: Sink[JmsMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(system, connectionFactory).withQueue("test") + ) + + val toRespondStreamCompletion: Future[Done] = + Source + .single( + JmsTextMessage(message) + .withHeader(JmsCorrelationId(correlationId)) + .withHeader(JmsReplyTo(tempQueueDest)) + ) + .runWith(toRespondSink) + + //#request-reply + val respondStreamControl: JmsConsumerControl = + JmsConsumer(JmsConsumerSettings(system, connectionFactory).withQueue("test")) + .collect { + case message: TextMessage => JmsTextMessage(message) + } + .map { textMessage => + textMessage.headers.foldLeft(JmsTextMessage(textMessage.body.reverse)) { + case (acc, rt: JmsReplyTo) => acc.to(rt.jmsDestination) + case (acc, cId: JmsCorrelationId) => acc.withHeader(cId) + case (acc, _) => acc + } + } + .via { + JmsProducer.flow( + JmsProducerSettings(system, connectionFactory).withQueue("ignored") + ) + } + .to(Sink.ignore) + .run() + //#request-reply + + // getting ConnectionRetryException when trying to listen using streams, assuming it's because + // a different session can't listen on the original session's TemporaryQueue + val consumer = session.createConsumer(tempQueue) + val result = Future(consumer.receive(5.seconds.toMillis))(system.dispatcher) + + toRespondStreamCompletion.futureValue shouldEqual Done + + val msg = result.futureValue + msg shouldBe a[TextMessage] + msg.getJMSCorrelationID shouldEqual correlationId + msg.asInstanceOf[TextMessage].getText shouldEqual message.reverse + + respondStreamControl.shutdown() + + connection.close() + } +} diff --git a/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala b/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala new file mode 100644 index 0000000000..ba3ee877a8 --- /dev/null +++ b/jakarta-jms/src/test/scala/docs/scaladsl/JmsSettingsSpec.scala @@ -0,0 +1,168 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.scaladsl + +import akka.stream.alpakka.jakartajms._ +import com.typesafe.config.{Config, ConfigFactory} +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory +import org.scalatest.OptionValues + +import scala.concurrent.duration._ + +class JmsSettingsSpec extends JmsSpec with OptionValues { + + private val connectionFactory = new ActiveMQConnectionFactory("vm://0") + + "Jms producer" should { + "have producer settings" in { + + //#retry-settings-case-class + // reiterating defaults from reference.conf + val retrySettings = ConnectionRetrySettings(system) + .withConnectTimeout(10.seconds) + .withInitialRetry(100.millis) + .withBackoffFactor(2.0d) + .withMaxBackoff(1.minute) + .withMaxRetries(10) + //#retry-settings-case-class + + //#send-retry-settings + // reiterating defaults from reference.conf + val sendRetrySettings = SendRetrySettings(system) + .withInitialRetry(20.millis) + .withBackoffFactor(1.5d) + .withMaxBackoff(500.millis) + .withMaxRetries(10) + //#send-retry-settings + + //#producer-settings + val producerConfig: Config = system.settings.config.getConfig(JmsProducerSettings.configPath) + val settings = JmsProducerSettings(producerConfig, connectionFactory) + .withTopic("target-topic") + .withCredentials(Credentials("username", "password")) + .withSessionCount(1) + //#producer-settings + + val retrySettings2 = ConnectionRetrySettings(system) + retrySettings.toString should be(retrySettings2.toString) + + val sendRetrySettings2 = SendRetrySettings(system) + sendRetrySettings.toString should be(sendRetrySettings2.toString) + + val producerSettings2 = JmsProducerSettings(producerConfig, settings.connectionFactory) + .withTopic("target-topic") + .withCredentials(Credentials("username", "password")) + settings.toString should be(producerSettings2.toString) + + } + } + + "Jms consumer" should { + "have consumer settings" in { + + val connectionRetryConfig: Config = system.settings.config.getConfig(ConnectionRetrySettings.configPath) + val retrySettings = ConnectionRetrySettings(connectionRetryConfig) + .withConnectTimeout(10.seconds) + .withInitialRetry(100.millis) + .withBackoffFactor(2.0d) + .withMaxBackoff(1.minute) + .withMaxRetries(10) + + //#consumer-settings + val consumerConfig: Config = system.settings.config.getConfig(JmsConsumerSettings.configPath) + // reiterating defaults from reference.conf + val settings = JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue("target-queue") + .withCredentials(Credentials("username", "password")) + .withConnectionRetrySettings(retrySettings) + .withSessionCount(1) + .withBufferSize(100) + .withAckTimeout(1.second) + //#consumer-settings + + val retrySettings2 = ConnectionRetrySettings(system) + retrySettings.toString should be(retrySettings2.toString) + + val consumerSettings2 = JmsConsumerSettings(consumerConfig, settings.connectionFactory) + .withQueue("target-queue") + .withCredentials(Credentials("username", "password")) + settings.toString should be(consumerSettings2.toString) + } + + "read from user config" in { + val config = ConfigFactory.parseString(""" + |connection-retry { + | connect-timeout = 10 seconds + | initial-retry = 100 millis + | backoff-factor = 2 + | max-backoff = 1 minute + | # infinite, or positive integer + | max-retries = 10 + |} + |credentials { + | username = "some text" + | password = "other text" + |} + |session-count = 10 + |buffer-size = 1000 + |selector = "some text" # optional + |acknowledge-mode = duplicates-ok + |ack-timeout = 5 second + """.stripMargin).withFallback(consumerConfig).resolve() + + val settings = JmsConsumerSettings(config, connectionFactory) + settings.credentials.value should be(Credentials("some text", "other text")) + settings.sessionCount should be(10) + settings.bufferSize should be(1000) + settings.selector.value should be("some text") + settings.acknowledgeMode.value should be(AcknowledgeMode.DupsOkAcknowledge) + settings.ackTimeout should be(5.seconds) + } + + "read numeric acknowledge mode" in { + val config = ConfigFactory.parseString(""" + |connection-retry { + | connect-timeout = 10 seconds + | initial-retry = 100 millis + | backoff-factor = 2 + | max-backoff = 1 minute + | # infinite, or positive integer + | max-retries = 10 + |} + |// credentials { + |// username = "some text" + |// password = "some text" + |// } + |session-count = 1 + |buffer-size = 100 + |// selector = "some text" # optional + |acknowledge-mode = 42 + |ack-timeout = 1 second + """.stripMargin).withFallback(consumerConfig).resolve() + + val settings = JmsConsumerSettings(config, connectionFactory) + settings.acknowledgeMode.value should be(new AcknowledgeMode(42)) + } + } + + "Browse settings" should { + "read from config" in { + val retrySettings = ConnectionRetrySettings(system) + //#browse-settings + val browseConfig: Config = system.settings.config.getConfig(JmsBrowseSettings.configPath) + val settings = JmsBrowseSettings(browseConfig, connectionFactory) + .withQueue("target-queue") + .withCredentials(Credentials("username", "password")) + .withConnectionRetrySettings(retrySettings) + //#browse-settings + + val settings2 = JmsBrowseSettings(browseConfig, settings.connectionFactory) + .withQueue("target-queue") + .withCredentials(Credentials("username", "password")) + + settings.toString should be(settings2.toString) + } + } +} diff --git a/jakarta-jms/src/test/scala/docs/scaladsl/JmsTxConnectorsSpec.scala b/jakarta-jms/src/test/scala/docs/scaladsl/JmsTxConnectorsSpec.scala new file mode 100644 index 0000000000..a16eb7a320 --- /dev/null +++ b/jakarta-jms/src/test/scala/docs/scaladsl/JmsTxConnectorsSpec.scala @@ -0,0 +1,721 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package docs.scaladsl + +import java.util.concurrent.{ConcurrentHashMap, LinkedBlockingQueue, TimeUnit} + +import akka.Done +import akka.stream._ +import akka.stream.alpakka.jakartajms._ +import akka.stream.alpakka.jakartajms.scaladsl.{JmsConsumer, JmsConsumerControl, JmsProducer} +import akka.stream.scaladsl.{Flow, Keep, RestartSource, Sink, Source} +import jakarta.jms.{JMSException, TextMessage} +import org.scalatest.Inspectors._ +import org.slf4j.LoggerFactory + +import scala.annotation.tailrec +import scala.collection.{immutable, mutable} +import scala.concurrent.{Await, Future, Promise} +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +class JmsTxConnectorsSpec extends JmsSpec { + + private final val log = LoggerFactory.getLogger(classOf[JmsTxConnectorsSpec]) + + override implicit val patienceConfig: PatienceConfig = PatienceConfig(2.minutes) + + "The JMS Transactional Connectors" should { + "publish and consume strings through a queue" in withConnectionFactory() { connectionFactory => + val queueName = createName("test") + val jmsSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in).runWith(jmsSink) + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val result = jmsSource + .take(in.size) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.commit(); text } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs in + } + + "publish and consume JMS text messages with properties through a queue" in withConnectionFactory() { + val queueName = createName("numbers") + connectionFactory => + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + + Source(msgsIn).runWith(jmsSink) + + //#source + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withAckTimeout(1.second) + .withQueue(queueName) + ) + + val result: Future[immutable.Seq[jakarta.jms.Message]] = + jmsSource + .take(msgsIn.size) + .map { txEnvelope => + txEnvelope.commit() + txEnvelope.message + } + .runWith(Sink.seq) + //#source + + // The sent message and the receiving one should have the same properties + val sortedResult = result.futureValue.sortBy(msg => msg.getIntProperty("Number")) + forAll(sortedResult.zip(msgsIn)) { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + } + } + + "ensure re-delivery when rollback JMS text messages through a queue" in withConnectionFactory() { + connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString).withProperty("Number", n) + } + + Source(msgsIn).runWith(jmsSink) + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val expectedElements = (1 to 100) ++ (2 to 100 by 2) map (_.toString) + + val rolledBackSet = ConcurrentHashMap.newKeySet[Int]() + + val result = jmsSource + .take(expectedElements.size) + .map { env => + val id = env.message.getIntProperty("Number") + if (id % 2 == 0 && !rolledBackSet.contains(id)) { + rolledBackSet.add(id) + env.rollback() + } else { + env.commit() + } + env.message.asInstanceOf[TextMessage].getText + } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs expectedElements + } + + "publish JMS text messages with properties through a queue and consume them with a selector" in withConnectionFactory() { + connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val msgsIn = 1 to 100 map { n => + JmsTextMessage(n.toString) + .withProperty("Number", n) + .withProperty("IsOdd", n % 2 == 1) + .withProperty("IsEven", n % 2 == 0) + } + Source(msgsIn).runWith(jmsSink) + + val jmsSource = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + .withSelector("IsOdd = TRUE") + ) + + val oddMsgsIn = msgsIn.filter(msg => msg.body.toInt % 2 == 1) + val result = jmsSource + .take(oddMsgsIn.size) + .map { env => + env.commit() + env.message + } + .runWith(Sink.seq) + // We should have only received the odd numbers in the list + + val sortedResult = result.futureValue.sortBy(msg => msg.getIntProperty("Number")) + forAll(sortedResult.zip(oddMsgsIn)) { + case (out, in) => + out.getIntProperty("Number") shouldEqual in.properties("Number") + out.getBooleanProperty("IsOdd") shouldEqual in.properties("IsOdd") + out.getBooleanProperty("IsEven") shouldEqual in.properties("IsEven") + // Make sure we are only receiving odd numbers + out.getIntProperty("Number") % 2 shouldEqual 1 + } + } + + "applying backpressure when the consumer is slower than the producer" in withConnectionFactory() { + connectionFactory => + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + val queueName = createName("test") + Source(in).runWith( + JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName)) + ) + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val result = jmsSource + .throttle(10, 1.second, 1, ThrottleMode.shaping) + .take(in.size) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.commit(); text } + .runWith(Sink.seq) + + result.futureValue should contain theSameElementsAs in + } + + "disconnection should fail the stage after exhausting retries" in withServer() { server => + val queueName = createName("test") + val connectionFactory = server.createConnectionFactory + val result = JmsConsumer + .txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue(queueName) + .withConnectionRetrySettings(ConnectionRetrySettings(system).withMaxRetries(3)) + ) + .runWith(Sink.seq) + Thread.sleep(500) + server.stop() + val ex = result.failed.futureValue + ex shouldBe a[ConnectionRetryException] + ex.getCause shouldBe a[JMSException] + } + + // Trying to illustrate https://github.com/akka/alpakka/issues/2103 + // Unfinished: this requires a broker persisting its data + "read messages after broker restart" ignore withServer() { server => + val queueName = createName("restarting") + val connectionFactory = server.createConnectionFactory + + val in = 0 to 25 map (i => ('a' + i).asInstanceOf[Char].toString) + Source(in) + .runWith( + JmsProducer.textSink(JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName)) + ) + .futureValue shouldBe Done + + val triggerRestart = Promise[Done]() + + val result = RestartSource + .onFailuresWithBackoff(RestartSettings(5.seconds, 1.minute, 0.25)) { () => + JmsConsumer + .txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withQueue(queueName) + ) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .log("received", _._2) + .map { + case tup @ (_, "e") => + triggerRestart.success(Done) + tup + case tup => + tup + } + .map { case (env, text) => env.commit(); text } + } + .take(in.size) + .runWith(Sink.seq) + + Await.result(triggerRestart.future, 20.seconds) + log.debug("-- Stopping broker") + server.stop() + Thread.sleep(1000) + log.debug("-- Re-starting broker") + server.start() + + result.futureValue should contain theSameElementsAs in + } + + "publish and consume elements through a topic " in withConnectionFactory() { connectionFactory => + import system.dispatcher + val topic = createName("topic") + + val jmsTopicSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic(topic) + ) + val jmsTopicSink2: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withTopic(topic) + ) + + val in = List("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k") + val inNumbers = (1 to 10).map(_.toString) + + val jmsTopicSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(1).withTopic(topic) + ) + val jmsSource2: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(1).withTopic(topic) + ) + + val expectedSize = in.size + inNumbers.size + val result1 = jmsTopicSource + .take(expectedSize) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.commit(); text } + .runWith(Sink.seq) + .map(_.sorted) + val result2 = jmsSource2 + .take(expectedSize) + .map(env => (env, env.message.asInstanceOf[TextMessage].getText)) + .map { case (env, text) => env.commit(); text } + .runWith(Sink.seq) + .map(_.sorted) + + //We wait a little to be sure that the source is connected + Thread.sleep(500) + + Source(in).runWith(jmsTopicSink) + Source(inNumbers).runWith(jmsTopicSink2) + + val expectedList: List[String] = in ++ inNumbers + result1.futureValue should contain theSameElementsAs expectedList + result2.futureValue should contain theSameElementsAs expectedList + } + + "ensure no message loss when stopping a stream" in withConnectionFactory() { connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.commit() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(1000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.commit() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + // messages might get delivered more than once, use set to ignore duplicates + resultList.toSet should contain theSameElementsAs numsIn.map(_.toString) + } + + "ensure no message loss when aborting a stream" in withConnectionFactory() { connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory).withSessionCount(5).withQueue(queueName) + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.commit() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(2000) + + val ex = new Exception("Test exception") + killSwitch.abort(ex) + + import system.dispatcher + val resultTry = streamDone.map(Success(_)).recover { case e => Failure(e) }.futureValue + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + resultTry shouldBe Failure(ex) + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.commit() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + // messages might get delivered more than once, use set to ignore duplicates + resultList.toSet should contain theSameElementsAs numsIn.map(_.toString) + } + + "ensure no message loss or starvation when exceptions occur in a stream missing commits" in withConnectionFactory() { + connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val r = new java.util.Random + + val thisDecider: Supervision.Decider = { + case ex => + Supervision.resume + } + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .map { env => + val text = env.message.asInstanceOf[TextMessage].getText + if (r.nextInt(3) <= 1) throw new IllegalStateException(s"Test Exception on $text") + resultQueue.add(text) + env.commit() + 1 + } + .recover { + case _: Throwable => 1 + } + .withAttributes(ActorAttributes.supervisionStrategy(thisDecider)) + .toMat(Sink.ignore)(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(3000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.commit() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + // messages might get delivered more than once, use set to ignore duplicates + (numsIn.map(_.toString).toSet -- resultList.toSet) shouldBe Set.empty[String] + resultList.toSet.toVector.sorted shouldBe numsIn.map(_.toString).sorted + } + + "ensure no message loss or starvation when timeouts occur in a stream processing" in withConnectionFactory() { + connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val (publishKillSwitch, publishedData) = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.seq)(Keep.both) + .run() + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + ) + + val resultQueue = new LinkedBlockingQueue[String]() + + val r = new java.util.Random + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + val text = env.message.asInstanceOf[TextMessage].getText + if (r.nextInt(3) <= 1) { + // Artificially timing out this message + Thread.sleep(20) + } + resultQueue.add(text) + env.commit() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(3000) + + killSwitch.shutdown() + + streamDone.futureValue shouldBe Done + + // Keep publishing for another 2 seconds to make sure we killed the consumption mid-stream. + Thread.sleep(2000) + + publishKillSwitch.shutdown() + val numsIn = publishedData.futureValue + + // Ensure we break the stream while reading, not all input should have been read. + resultQueue.size should be < numsIn.size + + val killSwitch2 = jmsSource + .to( + Sink.foreach { env => + resultQueue.add(env.message.asInstanceOf[TextMessage].getText) + env.commit() + } + ) + .run() + + val resultList = new mutable.ArrayBuffer[String](numsIn.size) + + @tailrec + def keepPolling(): Unit = + Option(resultQueue.poll(2, TimeUnit.SECONDS)) match { + case Some(entry) => + resultList += entry + keepPolling() + case None => + } + + keepPolling() + + killSwitch2.shutdown() + + // messages might get delivered more than once, use set to ignore duplicates + resultList.toSet should contain theSameElementsAs numsIn.map(_.toString) + } + + "fail the stream when ack-timeout causes a rollback (and fail-stream-on-ack-timeout is true)" in { + withConnectionFactory() { connectionFactory => + val queueName = createName("numbers") + val jmsSink: Sink[JmsTextMessage, Future[Done]] = JmsProducer.sink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + val publishKillSwitch = Source + .unfold(1)(n => Some(n + 1 -> n)) + .throttle(15, 1.second, 2, ThrottleMode.shaping) // Higher than consumption rate. + .viaMat(KillSwitches.single)(Keep.right) + .alsoTo(Flow[Int].map(n => JmsTextMessage(n.toString).withProperty("Number", n)).to(jmsSink)) + .toMat(Sink.ignore)(Keep.left) + .run() + + val jmsSource: Source[TxEnvelope, JmsConsumerControl] = JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(5) + .withQueue(queueName) + .withAckTimeout(10.millis) + .withFailStreamOnAckTimeout(true) + ) + + val r = new java.util.Random + + val (killSwitch, streamDone) = jmsSource + .throttle(10, 1.second, 2, ThrottleMode.shaping) + .toMat( + Sink.foreach { env => + if (r.nextInt(3) <= 1) { + // Artificially timing out this message + Thread.sleep(20) + } + env.commit() + } + )(Keep.both) + .run() + + // Need to wait for the stream to have started and running for sometime. + Thread.sleep(3000) + + killSwitch.shutdown() + + streamDone.failed.futureValue shouldBe a[JmsTxAckTimeout] + publishKillSwitch.shutdown() + } + } + + // Illustrates https://github.com/akka/alpakka/issues/2039 + "close the JMS session" in withConnectionFactory() { connectionFactory => + val queueName = createName("test") + val jmsSink: Sink[String, Future[Done]] = JmsProducer.textSink( + JmsProducerSettings(producerConfig, connectionFactory).withQueue(queueName) + ) + + Source.single("a").runWith(jmsSink) + + val jmsSource = + JmsConsumer.txSource( + JmsConsumerSettings(consumerConfig, connectionFactory) + .withSessionCount(1) + .withAckTimeout(1.second) + .withQueue(queueName) + .withFailStreamOnAckTimeout(true) + ) + + val streamCompletion = jmsSource + // Let the ack timeout kick in + .delay(2.seconds) + .map { txEnvelope => + txEnvelope.commit() + txEnvelope.message.asInstanceOf[TextMessage] + } + .runWith(Sink.head) + + streamCompletion.failed.futureValue shouldBe a[akka.stream.alpakka.jakartajms.JmsTxAckTimeout] + + val streamCompletion2 = jmsSource + .map { txEnvelope => + txEnvelope.commit() + txEnvelope.message.asInstanceOf[TextMessage] + } + .runWith(Sink.head) + + streamCompletion2.futureValue.getText shouldBe "a" + } + } +} diff --git a/jakarta-jms/src/test/scala/jakartajmstestkit/JmsBroker.scala b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsBroker.scala new file mode 100644 index 0000000000..079eb6986e --- /dev/null +++ b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsBroker.scala @@ -0,0 +1,74 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package jakartajmstestkit + +import org.apache.activemq.artemis.api.core.management.QueueControl +import org.apache.activemq.artemis.api.core.management.ResourceNames +import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl +import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory +import org.apache.activemq.artemis.jms.client.ActiveMQQueueConnectionFactory +import org.apache.activemq.artemis.jms.client.ActiveMQTopicConnectionFactory + +/** + * This testkit was copied from https://github.com/sullis/jms-testkit with modifications + * to support Jakarta Messaging. Replacing `javax.jms` with `jakarta.jms`. + * ActiveMQ replaced with Artemis EmbeddedActiveMQ. + * jms-testkit is licensed under the Apache License, Version 2.0. + */ +class JmsBroker(val broker: EmbeddedActiveMQ) { + + def isStarted: Boolean = broker.getActiveMQServer.isStarted + + def brokerUri: String = "vm://0" + + def createQueueConnectionFactory: jakarta.jms.QueueConnectionFactory = { + new ActiveMQQueueConnectionFactory(brokerUri) + } + + def createTopicConnectionFactory: jakarta.jms.TopicConnectionFactory = { + new ActiveMQTopicConnectionFactory(brokerUri) + } + + def createConnectionFactory: jakarta.jms.ConnectionFactory = { + new ActiveMQConnectionFactory(brokerUri) + } + + def start(): Unit = broker.start() + + def restart(): Unit = { + stop() + start() + } + + def stop(): Unit = broker.getActiveMQServer.stop() + + def getQueueSize(queueName: String): Long = { + val queueControl = + broker.getActiveMQServer.getManagementService + .getResource(ResourceNames.QUEUE + queueName) + .asInstanceOf[QueueControl] + queueControl.getMessageCount + } + + override def toString(): String = { + getClass.getSimpleName + s"[$brokerUri]" + } + +} + +object JmsBroker { + def apply(): JmsBroker = { + val config = new ConfigurationImpl + config.addAcceptorConfiguration("in-vm", "vm://0") + config.setSecurityEnabled(false) + config.setPersistenceEnabled(false) + val broker = new EmbeddedActiveMQ + broker.setConfiguration(config) + broker.start() + + new JmsBroker(broker) + } +} diff --git a/jakarta-jms/src/test/scala/jakartajmstestkit/JmsQueue.scala b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsQueue.scala new file mode 100644 index 0000000000..0f814dc604 --- /dev/null +++ b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsQueue.scala @@ -0,0 +1,71 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package jakartajmstestkit + +import java.util.{Collections, UUID} + +import jakarta.jms.{ConnectionFactory, QueueConnectionFactory, TextMessage} + +import scala.collection.JavaConverters._ +import scala.util.Try + +/** + * This testkit was copied from https://github.com/sullis/jms-testkit with modifications + * to support Jakarta Messaging. Replacing `javax.jms` with `jakarta.jms`. + * ActiveMQ replaced with Artemis EmbeddedActiveMQ. + * jms-testkit is licensed under the Apache License, Version 2.0. + */ +class JmsQueue(val broker: JmsBroker) { + + val queueName: String = "Queue-" + UUID.randomUUID.toString + + def size: Long = calculateQueueSize(queueName) + + def createQueueConnectionFactory: QueueConnectionFactory = broker.createQueueConnectionFactory + def createConnectionFactory: ConnectionFactory = broker.createConnectionFactory + + private def calculateQueueSize(qName: String): Long = { + broker.getQueueSize(qName) + } + + def toSeq: Seq[String] = { + val qconn = createQueueConnectionFactory.createQueueConnection() + val session = qconn.createQueueSession(false, jakarta.jms.Session.AUTO_ACKNOWLEDGE) + val queue = session.createQueue(queueName) + qconn.start + val browser = session.createBrowser(queue) + val result = + Collections.list(browser.getEnumeration).asScala.asInstanceOf[Iterable[TextMessage]].map(_.getText).toSeq + Try { browser.close() } + Try { session.close() } + Try { qconn.close() } + result + } + + def toJavaList: java.util.List[String] = { + java.util.Collections.unmodifiableList(toSeq.asJava) + } + + def publishMessage(msg: String): Unit = { + val qconn = createQueueConnectionFactory.createQueueConnection() + val session = qconn.createQueueSession(false, jakarta.jms.Session.AUTO_ACKNOWLEDGE) + val queue = session.createQueue(queueName) + val sender = session.createSender(queue) + sender.send(session.createTextMessage(msg)) + Try { sender.close() } + Try { session.close() } + Try { qconn.close() } + } + + override def toString(): String = { + getClass.getSimpleName + s"[${queueName}]" + } +} + +object JmsQueue { + def apply(): JmsQueue = { + new JmsQueue(JmsBroker()) + } +} diff --git a/jakarta-jms/src/test/scala/jakartajmstestkit/JmsTestKit.scala b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsTestKit.scala new file mode 100644 index 0000000000..a04339bf25 --- /dev/null +++ b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsTestKit.scala @@ -0,0 +1,50 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package jakartajmstestkit + +/** + * This testkit was copied from https://github.com/sullis/jms-testkit with modifications + * to support Jakarta Messaging. Replacing `javax.jms` with `jakarta.jms`. + * ActiveMQ replaced with Artemis EmbeddedActiveMQ. + * jms-testkit is licensed under the Apache License, Version 2.0. + */ +trait JmsTestKit { + def withBroker()(test: JmsBroker => Unit): Unit = { + val broker = JmsBroker() + try { + test(broker) + Thread.sleep(500) + } finally { + broker.stop() + } + } + + def withConnectionFactory()(test: jakarta.jms.ConnectionFactory => Unit): Unit = { + withBroker() { broker => + test(broker.createConnectionFactory) + } + } + + def withQueue()(test: JmsQueue => Unit): Unit = { + val queue = JmsQueue() + try { + test(queue) + Thread.sleep(500) + } finally { + queue.broker.stop() + } + } + + def withTopic()(test: JmsTopic => Unit): Unit = { + val topic = JmsTopic() + try { + test(topic) + Thread.sleep(500) + } finally { + topic.broker.stop() + } + } + +} diff --git a/jakarta-jms/src/test/scala/jakartajmstestkit/JmsTopic.scala b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsTopic.scala new file mode 100644 index 0000000000..d82e61a48e --- /dev/null +++ b/jakarta-jms/src/test/scala/jakartajmstestkit/JmsTopic.scala @@ -0,0 +1,70 @@ +/* + * Copyright (C) since 2016 Lightbend Inc. + */ + +package jakartajmstestkit + +import java.util.UUID +import jakarta.jms.{ConnectionFactory, Message, MessageListener, TextMessage, TopicConnectionFactory} +import scala.util.Try +import scala.collection.mutable.ListBuffer +import scala.collection.JavaConverters._ + +/** + * This testkit was copied from https://github.com/sullis/jms-testkit with modifications + * to support Jakarta Messaging. Replacing `javax.jms` with `jakarta.jms`. + * ActiveMQ replaced with Artemis EmbeddedActiveMQ. + * jms-testkit is licensed under the Apache License, Version 2.0. + */ +class JmsTopic(val broker: JmsBroker) { + val topicName: String = "Topic-" + UUID.randomUUID.toString + + private val mutableList = ListBuffer[String]() + + registerMessageListener() + + private def registerMessageListener(): Unit = { + val conn = this.createTopicConnectionFactory.createTopicConnection() + conn.start() + val session = conn.createTopicSession(true, jakarta.jms.Session.AUTO_ACKNOWLEDGE) + val topic = session.createTopic(topicName) + session + .createSubscriber(topic) + .setMessageListener(new MessageListener() { + override def onMessage(message: Message): Unit = { + mutableList += message.asInstanceOf[TextMessage].getText + } + }) + } + + def createTopicConnectionFactory: TopicConnectionFactory = broker.createTopicConnectionFactory + + def createConnectionFactory: ConnectionFactory = broker.createConnectionFactory + + def publishMessage(msg: String): Unit = { + val conn = createTopicConnectionFactory.createTopicConnection() + val session = conn.createTopicSession(false, jakarta.jms.Session.AUTO_ACKNOWLEDGE) + val topic = session.createTopic(topicName) + val publisher = session.createPublisher(topic) + publisher.send(session.createTextMessage(msg)) + Try { publisher.close() } + Try { session.close() } + Try { conn.close() } + } + + def toSeq: Seq[String] = mutableList.toList + + def toJavaList: java.util.List[String] = { + java.util.Collections.unmodifiableList(toSeq.asJava) + } + + override def toString(): String = { + getClass.getSimpleName + s"[${topicName}]" + } +} + +object JmsTopic { + def apply(): JmsTopic = { + new JmsTopic(JmsBroker()) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 770adcc178..7ed2c93db8 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -324,6 +324,16 @@ object Dependencies { ) ) + val JakartaJms = Seq( + libraryDependencies ++= Seq( + "jakarta.jms" % "jakarta.jms-api" % "3.1.0", // Eclipse Public License 2.0 + + GPLv2 + "org.apache.activemq" % "artemis-jakarta-server" % "2.31.2" % Test, // ApacheV2 + "org.apache.activemq" % "artemis-jakarta-client" % "2.31.2" % Test, // ApacheV2 + // slf4j-api 2.0.9 via activemq-client + "ch.qos.logback" % "logback-classic" % "1.4.12" % Test // Eclipse Public License 1.0 + ) ++ Mockito + ) + val Jms = Seq( libraryDependencies ++= Seq( "javax.jms" % "jms" % "1.1" % Provided, // CDDL + GPLv2 diff --git a/project/project-info.conf b/project/project-info.conf index e769f2f21f..46fa72e609 100644 --- a/project/project-info.conf +++ b/project/project-info.conf @@ -521,6 +521,24 @@ project-info { } ] } + jakarta-jms: ${project-info.shared-info} { + title: "Alpakka JMS Jakarta" + jpms-name: "akka.stream.alpakka.jmsjakarta" + issues.url: ${project-info.labels}"jms" + levels: [ + { + readiness: CommunityDriven + since: "2023-11-28" + since-version: "7.0.1" + } + ] + api-docs: [ + { + url: ${project-info.scaladoc}"jakarta-jms/index.html" + text: "API (Scaladoc)" + } + ] + } json-streaming: ${project-info.shared-info} { title: "Alpakka JSON Streaming" jpms-name: "akka.stream.alpakka.json.streaming"