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.