Azure Service Bus transport

Last updated:

BabelQueue.AzureServiceBus is an Azure Service Bus transport on the .NET core. It sends the canonical envelope as the 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

dotnet add package BabelQueue.AzureServiceBus

Requirements: .NET 8. It pulls BabelQueue.Core and Azure.Messaging.ServiceBus transitively.

Produce

using Azure.Messaging.ServiceBus;
using BabelQueue.AzureServiceBus;

await using var client = new ServiceBusClient("<connection-string>"); // or a namespace + TokenCredential

var sender = client.CreateSender("orders");
var id = await new AsbPublisher(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) to schedule native delayed delivery (ScheduledEnqueueTime).

Consume

var receiver = client.CreateReceiver("orders"); // PeekLock
var handlers = new Dictionary<string, BabelHandler>
{
    ["urn:babel:orders:created"] = (envelope, message, ct) =>
    {
        // envelope.Data, envelope.TraceId, envelope.Attempts ...
        return Task.CompletedTask;
    },
};
var consumer = new AsbConsumer(receiver, handlers, new AsbConsumerOptions
{
    OnError = (err, env, msg) => Console.Error.WriteLine(err),
});
await consumer.RunAsync(cancellationToken);

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 valid UTF-8, 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 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.

What happens next depends on whether you set AsbConsumerOptions.DeadLetterSender, a ServiceBusSender on the <queue>.dlq entity (an ordinary Service Bus queue that must exist):

var dlq = client.CreateSender("orders.dlq");
var consumer = new AsbConsumer(receiver, handlers, new AsbConsumerOptions
{
    DeadLetterSender = dlq,
    Queue = "orders", // dead_letter.original_queue; defaults to the receiver's entity path
});
Configuration Poison body Version-gated message
DeadLetterSender set sent to <queue>.dlq as the poison record (a strict JSON object body spliced with a dead_letter block, reason: "poison"; 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, 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 succeeded; a failed send Abandons it and reports the error, so the write is tried again 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; the OnUnknownUrn callback applies to an unknown URN only.

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

ServiceBusSender / ServiceBusReceiver are mockable, so the unit tests use Moq + ServiceBusModelFactory — no Azure, no network. The envelope is unchanged (schema_version stays 1); Azure Service Bus is purely additive.