Apache ActiveMQ Artemis transport

Last updated:

BabelQueue.Artemis is an Apache ActiveMQ Artemis transport on the .NET core, built on AMQP 1.0 (AMQPNetLite). 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 gives the binding native settlement, scheduled delivery, a delivery counter and a dead-letter address, so it maps onto them rather than re-implementing.

Install

dotnet add package BabelQueue.Artemis

Requirements: .NET 8. It pulls BabelQueue.Core and AMQPNetLite.Core transitively.

Produce

using Amqp;
using BabelQueue.Artemis;

var connection = new Connection(new Address("amqp://user:pass@localhost:5672"));
var session = new Session(connection);
var sender = new AmqpSenderLink(new SenderLink(session, "orders-sender", "orders"), "orders");

var id = await new ArtemisPublisher(sender)
    .PublishAsync("urn:babel:orders:created", new Dictionary<string, object?> { ["order_id"] = 1042 });

PublishAsync returns the message meta.id; pass a traceId to continue a trace, or a delay (TimeSpan) for native AMQP scheduled delivery.

Consume

using Amqp;
using BabelQueue;
using BabelQueue.Artemis;

var receiver = new AmqpReceiverLink(new ReceiverLink(session, "orders-worker", "orders"));
var dlq = new AmqpSenderLink(new SenderLink(session, "dlq-sender", "orders.dlq"), "orders.dlq");

var handlers = new Dictionary<string, BabelHandler>
{
    ["urn:babel:orders:created"] = (envelope, message, ct) =>
    {
        // envelope.Data, envelope.TraceId, envelope.Attempts ...
        return Task.CompletedTask;
    },
};

var options = new ArtemisConsumerOptions { DeadLetterSender = dlq, MaxTries = 3 };
var worker = new ArtemisConsumer(receiver, handlers, options);
await worker.RunAsync(cancellationToken); // receive → process → Accept

A successful handler Accepts the message. A throwing handler settles it as Modified{delivery-failed: true} (since 1.1.0): the broker redelivers it and bumps delivery-count, and once MaxTries is reached the envelope goes to <queue>.dlq with a dead_letter block. Set the broker’s max-delivery-attempts to at least MaxTries, or Artemis moves the message to its native DLA first. A custom IAmqpReceiver must override ModifyAsync; the default falls back to ReleaseAsync, which does not bump the counter. The consumer routes on the x-opt-jms-type annotation (falling back to the body URN). Unknown-URN strategy is one of fail / delete / release / dead_letter.

AmqpSenderLink / AmqpReceiverLink wrap the AMQPNetLite links; the publisher and consumer depend on the IAmqpSender / IAmqpReceiver seams, so they unit-test against mocks with no broker.

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.2.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 DeadLetterSender it is written to <queue>.dlq as the poison record: a strict JSON object body is spliced byte for byte with a dead_letter block (reason: "poison"), anything else is wrapped under raw (base64 plus raw_encoding when it is not valid UTF-8). dead_letter.original_queue is ArtemisConsumerOptions.Queue, which defaults to the DeadLetterSender address without its .dlq suffix. The message is Accepted only after the write. Earlier releases forwarded the raw body, so a reader of <queue>.dlq must not assume every record is a canonical envelope.
  • A version-gated message — the bq_schema_version application property is present, not blank and not 1 (§7.7). A missing or blank value (the empty string, or only ASCII whitespace: space, tab, LF, VT, FF, CR) changes nothing: the body is decoded as usual. Any other value is compared with "1" exactly as sent, with no trimming, so "2", "x", "01" and " 1" are all rejected without decoding. The raw body, copied as received (a binary body stays binary) and with no dead_letter block, is written to <queue>.dlq and the message is then Accepted. OnError is called with an empty 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.
Configuration Poison body Version-gated message
DeadLetterSender set poison record to <queue>.dlq, then Accept raw body to <queue>.dlq, then Accept
UnknownUrn = delete Accepted, no record Accepted, no record (dropped)
no DeadLetterSender Accepted, dropped, trace warning Accepted, dropped, trace warning
the DLQ write fails Released and reported, redelivered and written again same

Only the UnknownUrn strategy you set is read here (release behaves as fail).

Contract mapping (§7)

Envelope Apache ActiveMQ Artemis (AMQP 1.0)
body message body (byte-identical across SDKs)
job (URN) x-opt-jms-type annotation → JMSType (consumer routes on this)
trace_id correlation-id → JMSCorrelationID
meta.created_at creation-time → JMSTimestamp (Unix ms)
meta.schema_version application 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 application property bq_source_lang
attempts max(body, delivery-count) (AMQP counter is 0-based)
reserve / ack Receive → process → Accept
retry / delay Modified{delivery-failed} redelivery · native x-opt-delivery-time
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 AMQP send/receive seams are mocked with Moq — no Artemis, no network. The envelope is unchanged (schema_version stays 1); Apache ActiveMQ Artemis is purely additive.