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
jobnor a validurn, or carries an unsupportedmeta.schema_version. With aDeadLetterSenderit is written to<queue>.dlqas the poison record: a strict JSON object body is spliced byte for byte with adead_letterblock (reason: "poison"), anything else is wrapped underraw(base64 plusraw_encodingwhen it is not valid UTF-8).dead_letter.original_queueisArtemisConsumerOptions.Queue, which defaults to theDeadLetterSenderaddress without its.dlqsuffix. The message isAccepted only after the write. Earlier releases forwarded the raw body, so a reader of<queue>.dlqmust not assume every record is a canonical envelope. - A version-gated message — the
bq_schema_versionapplication property is present, not blank and not1(§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 nodead_letterblock, is written to<queue>.dlqand the message is thenAccepted.OnErroris called with an empty envelope. A"1"value does not skip the body check: the decoded envelope still goes through theschema_versionvalidation, 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.