Azure Service Bus transport
Last updated:
com.babelqueue:babelqueue-azure-servicebus is an Azure Service Bus transport on the Java
core. It sends the canonical envelope as the message body with
the §4 native 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.
Install
Maven:
<dependency>
<groupId>com.babelqueue</groupId>
<artifactId>babelqueue-azure-servicebus</artifactId>
<version>1.0.2</version>
</dependency>
Requirements: Java 17+. It pulls babelqueue-core and azure-messaging-servicebus
transitively. You supply the Azure ServiceBusClient.
Produce
import com.babelqueue.azureservicebus.AsbPublisher;
import com.azure.messaging.servicebus.ServiceBusClientBuilder;
import com.azure.messaging.servicebus.ServiceBusSenderClient;
import java.util.Map;
ServiceBusClientBuilder builder = new ServiceBusClientBuilder().connectionString(cs);
ServiceBusSenderClient sender = builder.sender().queueOrTopicName("orders").buildClient();
String id = AsbPublisher.create(sender)
.publish("urn:babel:orders:created", Map.of("order_id", 1042L));
publish(urn, data) returns the message meta.id; overloads add a traceId and a
relative Duration delay (native ScheduledEnqueueTime).
Consume
import com.babelqueue.azureservicebus.AsbConsumer;
import com.azure.messaging.servicebus.ServiceBusReceiverClient;
ServiceBusReceiverClient receiver = builder.receiver().queueName("orders").buildClient();
AsbConsumer consumer = AsbConsumer.builder(receiver)
.handler("urn:babel:orders:created", (envelope, message) -> {
// envelope.data(), envelope.traceId(), envelope.attempts() ...
})
.onError((error, envelope, message) -> error.printStackTrace())
.build();
while (running) {
consumer.poll();
}
A throwing handler abandons the message — the broker redelivers it and increments
DeliveryCount (at-least-once); at MaxDeliveryCount it auto-moves to the native
dead-letter sub-queue. Auth is a connection string or the fully-qualified namespace + a
TokenCredential (DefaultAzureCredential).
Poison bodies and version-gated messages
A poison body (not JSON, not an envelope, no valid job or urn, or an unsupported
meta.schema_version) and a version-gated message 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).
Before decoding, the consumer version-gates on the bq-schema-version application property
(§4.7). A string "1" or any integral 1 passes; a floating-point or boolean value is
rejected. 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. 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 the body, and
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 next depends on whether you gave the consumer a <queue>.dlq sender, an ordinary
Service Bus queue that must exist and that you own:
ServiceBusSenderClient dlq = builder.sender().queueOrTopicName("orders.dlq").buildClient();
AsbConsumer consumer = AsbConsumer.builder(receiver)
.handler("urn:babel:orders:created", (envelope, message) -> { /* ... */ })
.deadLetterSender(dlq)
.build();
| Configuration | Poison body | Version-gated message |
|---|---|---|
deadLetterSender set |
sent to <queue>.dlq as the poison record (a JSON object body spliced with a dead_letter block, reason: "poison", the exact body bytes kept; anything else wrapped under raw), then completed |
the raw body, byte for byte and with no dead_letter block, sent to <queue>.dlq, then completed |
no deadLetterSender |
abandoned and reported, so MaxDeliveryCount moves it to the native sub-queue |
explicitly dead-lettered into the native $DeadLetterQueue (reason unsupported schema version) |
The original is completed only after the send to <queue>.dlq succeeded; a failed send
abandons it, reports the error and retries the write on redelivery. deadLetterSender is
opt-in: with none, nothing changes and the native sub-queue stays your safety net. With one set,
abandoning towards the native sub-queue is no longer a conformant settle. This consumer has no
unknown-URN strategy option, so it behaves as fail, and an onUnknownUrn hook never affects a
poison or version-gated message.
Contract mapping (§4)
| Envelope | Azure Service Bus |
|---|---|
| body | Body (byte-identical across SDKs) |
job (URN) |
Subject |
trace_id |
CorrelationId |
meta.id |
MessageId |
meta.schema_version |
ApplicationProperties["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 |
ApplicationProperties["bq-source-lang"] |
meta.created_at |
ApplicationProperties["bq-created-at"] (ms) |
attempts |
max(body, DeliveryCount − 1) |
| reserve / ack / retry | PeekLock → complete / abandon |
The final Service Bus clients are mocked with Mockito 5 — no Azure, no network. The
envelope is unchanged (schema_version stays 1); Azure Service Bus is purely additive.