Apache ActiveMQ Artemis transport

Last updated:

com.babelqueue:babelqueue-artemis is an Apache ActiveMQ Artemis transport on the Java core, built on JMS (Jakarta Messaging 3.x). It sends the canonical envelope as the message body with the §7 projection, and consumes by routing each message to a handler by URN — so a message it produces is consumed by any other BabelQueue SDK, and vice-versa. Artemis speaks AMQP 1.0, so the other SDKs interoperate over the wire even though Java uses JMS.

Install

Maven:

<dependency>
    <groupId>com.babelqueue</groupId>
    <artifactId>babelqueue-artemis</artifactId>
    <version>1.0.3</version>
</dependency>

Requirements: Java 17+. It pulls babelqueue-core transitively; bring your Artemis JMS client (org.apache.activemq:artemis-jakarta-client), which supplies both the jakarta.jms API and the broker connection.

Produce

import com.babelqueue.artemis.ArtemisPublisher;
import jakarta.jms.*;
import org.apache.activemq.artemis.jms.client.ActiveMQJMSConnectionFactory;
import java.util.Map;

ConnectionFactory factory = new ActiveMQJMSConnectionFactory("tcp://localhost:61616");
try (Connection connection = factory.createConnection("user", "pass")) {
    Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE);
    MessageProducer producer = session.createProducer(session.createQueue("orders"));

    String id = ArtemisPublisher.create(session, producer)
        .publish("urn:babel:orders:created", Map.of("order_id", 1042L));
}

ArtemisPublisher takes a classic JMS Session (from Connection.createSession), not a JMSContext. publish(...) throws the checked JMSException, so declare throws JMSException on the enclosing method (or catch it).

publish(urn, data) returns the message meta.id; overloads add a traceId and a relative Duration delay (native JMS 2.0 setDeliveryDelay).

Consume

import com.babelqueue.artemis.*;
import jakarta.jms.*;

Session session = connection.createSession(false, Session.CLIENT_ACKNOWLEDGE);
MessageConsumer consumer = session.createConsumer(session.createQueue("orders"));
MessageProducer dlqProducer = session.createProducer(null); // anonymous, for <queue>.dlq

ArtemisConsumer worker = ArtemisConsumer.builder(consumer, session)
    .handler("urn:babel:orders:created", (envelope, message) -> {
        // envelope.data(), envelope.traceId(), envelope.attempts() ...
    })
    .deadLetterQueue(dlqProducer, "orders.dlq")
    .maxTries(3)
    .build();

connection.start();
worker.run(() -> true); // receive → process → acknowledge

A successful handler acknowledges the message. A throwing handler leaves it unacknowledged and recovers the session, so the broker redelivers it and bumps JMSXDeliveryCount; once maxTries is reached the envelope goes to <queue>.dlq with a dead_letter block. The consumer routes on JMSType. Unknown-URN strategy is one of fail / delete / release / dead_letter.

If a <queue>.dlq write fails on any of these paths (a handler that has run out of tries, an unknown URN with the dead_letter strategy, a poison body, a version-gated message), the message is not acknowledged: the consumer recovers the session, reports the error to onError, and the broker redelivers it, so the handler may run again. Without this, the session-wide acknowledge() of the next message would also have acknowledged the one whose DLQ write failed. Keep handlers idempotent.

One message per poll() keeps the session-wide acknowledge() / recover() correct — a JMS session is single-threaded, so run one ArtemisConsumer per thread.

Poison bodies and version-gated messages

Two kinds of delivery can never succeed, so the consumer settles them on their first delivery, never hands them to a handler and never retries them (ADR-0037, since 1.1.0):

  • A poison body — it is not JSON, is not an envelope, has neither a valid job nor a valid urn, or carries an unsupported meta.schema_version. With a dead-letter queue it is written to <queue>.dlq as the poison record: a JSON object body is spliced byte for byte with a dead_letter block (reason: "poison", original_queue = the consumed queue), anything else is wrapped under raw (base64 plus raw_encoding when it is not valid UTF-8). The message is acknowledged only after the write. Earlier releases forwarded the raw body instead, so a reader of <queue>.dlq must not assume every record is a canonical envelope.
  • A version-gated message — the bq_schema_version message property is present, not blank and not 1 (§7.7). The body is never decoded; the raw body, byte for byte and with no dead_letter block, is written to <queue>.dlq and the message is then acknowledged.

A missing or blank bq_schema_version (the empty string, or only ASCII whitespace: space, tab, LF, VT, FF, CR) changes nothing: the body is decoded as usual. A property present with a null value counts as missing too. Any other value is compared with "1" exactly as sent, with no trimming, so "2", "x", "01" and " 1" are all rejected without decoding. onError is called with a null envelope. A "1" value does not skip the body check: the decoded envelope still goes through the schema_version validation, and a body that fails it is a poison body.

What happens around the DLQ write:

Configuration Poison body Version-gated message
dead-letter queue set poison record to <queue>.dlq, then acknowledge raw body to <queue>.dlq, then acknowledge
unknownUrn(DELETE) acknowledged, no record acknowledged, no record (dropped)
no dead-letter queue acknowledged, dropped, logged acknowledged, dropped, logged
the DLQ write fails recover(), reported, redelivered and written again same

The consumer reads only the strategy you set explicitly with unknownUrn(...): with none set it behaves as fail. Artemis’s own max-delivery-attempts is not involved in either case.

Contract mapping (§7)

Envelope Apache ActiveMQ Artemis (JMS)
body message body (TextMessage, byte-identical across SDKs)
job (URN) JMSType (consumer routes on this)
trace_id JMSCorrelationID
meta.id JMSMessageID (broker-set for JMS; body is authoritative)
meta.schema_version property bq_schema_version (set on publish; the consumer checks it before decoding the body, and also validates the body’s meta.schema_version after decoding)
meta.lang property bq_source_lang
meta.created_at JMSTimestamp (Unix ms)
attempts max(body, JMSXDeliveryCount − 1) (broker counter is 1-based)
reserve / ack receive → process → acknowledge() (CLIENT_ACKNOWLEDGE)
retry / delay recover() redelivery · native setDeliveryDelay
dead-letter <queue>.dlq + dead_letter block (alongside the native DLA); a poison body is the ADR-0035 record, a version-gated message the raw body

The JMS interfaces are mocked with Mockito — no Artemis, no network. The envelope is unchanged (schema_version stays 1); Apache ActiveMQ Artemis is purely additive.