Apache Pulsar transport
Last updated:
BabelQueue.Pulsar is an Apache Pulsar transport on the .NET core. It sends the
canonical envelope as the message payload with the
§5 property 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.Pulsar
Requirements: .NET 8. It pulls BabelQueue.Core and DotPulsar (a pure-C# Pulsar
client) transitively.
Produce
using DotPulsar;
using DotPulsar.Extensions;
using BabelQueue.Pulsar;
await using var client = PulsarClient.Builder()
.ServiceUrl(new Uri("pulsar://localhost:6650")).Build();
await using var producer = client.NewProducer(Schema.ByteArray).Topic("orders").Create();
var id = await new PulsarPublisher(producer)
.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 (DeliverAtTime).
Consume
await using var consumer = client.NewConsumer(Schema.ByteArray)
.Topic("orders").SubscriptionName("babelqueue")
.SubscriptionType(SubscriptionType.Shared).Create();
// Optional: a producer on the dead-letter topic turns dead-lettering on.
await using var dlq = client.NewProducer(Schema.ByteArray).Topic("orders.dlq").Create();
var handlers = new Dictionary<string, BabelHandler>
{
["urn:babel:orders:created"] = (envelope, message, ct) =>
{
// envelope.Data, envelope.TraceId, envelope.Attempts ...
return Task.CompletedTask;
},
};
await using var babel = new PulsarConsumer(consumer, handlers, new PulsarConsumerOptions
{
Client = client, // the consumer builds its own retry producer from it
MaxAttempts = 3, // the default: 3 deliveries in total, the first included
DeadLetterProducer = dlq, // omit it and an exhausted message is dropped and logged
OnError = (err, env, msg) => Console.Error.WriteLine(err),
});
await babel.RunAsync(cancellationToken);
Retry, max attempts and the DLQ
Redelivery is bounded by the SDK. Pulsar has no broker-side redelivery limit, and
DotPulsar 5.3.1 has no client-side DeadLetterPolicy, so the consumer counts the attempts
itself. MaxAttempts is the total number of deliveries a message gets, the first included;
the default is 3 and 1 means no retry. A value below 1 makes the constructor throw
ArgumentOutOfRangeException.
- Counted attempt. On every delivery the consumer takes the reconciled attempts (below)
and adds one for the current delivery. A throwing handler, or an unmapped URN without an
OnUnknownUrnhook, is retried while that counted attempt is belowMaxAttempts. - A retry is a re-publish. The consumer re-publishes the message to its own topic with
bq-attempts + 1(the body re-encoded by the codec withattemptsin sync) and then acknowledges the original, but only after the publish succeeded; a failed publish leaves the original for redelivery, so nothing is lost. A negative acknowledgement advancesRedeliveryCountonly onSharedandKey_Sharedsubscriptions, so the re-publish is what makes the cap hold on every subscription type. A republished message loses its original order and anyDeliverAtschedule. - Giving the consumer a producer.
Client(theIPulsarClientthe consumer was built from) lets the consumer create the retry producer itself onconsumer.Topic, which must be a single topic;PulsarConsumerimplementsIAsyncDisposableto dispose it.RetryProducersupplies your own instead (used as given, never disposed by the consumer). With neither, a retry is a negative acknowledgement, which only terminates onSharedandKey_Shared: the constructor throwsInvalidOperationExceptionfor anExclusiveorFailoversubscription without one of them, and also when the subscription type is not reported (it is then treated asExclusive, the fail-closed choice), instead of looping at run time. - At the cap. With
DeadLetterProducerset (a producer on<queue>.dlq) the message is written there as the canonical envelope plus thedead_letterblock (reasonfailedorunknown_urn,attempts= the counted value,lang: "dotnet"), keeping thebq-jobroute and the other incoming properties. Without it the message is acknowledged and dropped, and a trace warning is written. The original is acknowledged only after the DLQ write succeeded; a failed write is reported toOnErrorand the message stays unacknowledged, so it is redelivered. - Poison. A body that never reaches a handler (not valid UTF-8, not a JSON object, no
valid
job/urnidentity, any other non-conformant envelope, or a body with an unsupportedmeta.schema_version) is poison. A message rejected by thebq-schema-versionproperty gate below is not poison: it is settled on its first delivery. WithDeadLetterProducerit is written to<queue>.dlqat once, without retry, as a poison record (reason: "poison", the original bytes kept, the message’s properties carried over); a body that does not decode no longer gets rejected and redelivered forever. Without a producer it takes the bounded retry and is then dropped. OnUnknownUrnis unchanged. When the hook is set it runs and the message is acknowledged; the bounded path above is for an unmapped URN without a hook.
Behaviour change. Before this release a throwing handler was redelivered without a limit. With the defaults, a message that keeps failing is now dropped (or dead-lettered) at its third delivery; raise
MaxAttemptsto keep retrying longer.
Per §5.7, the consumer routes on the
bq-job property (the URN), falling back to the body’s job / urn only when the property
is absent or blank (externally produced messages). The body is still decoded and the handler
receives the body’s envelope. If bq-job and the body’s job differ, the property still
routes and the optional OnRoutingMismatch hook (since 1.1.0) is called with both URNs (a throwing hook
is swallowed; settling is unaffected). attempts comes from bq-attempts when it is a
non-negative integer (^[0-9]+$ that fits an int), otherwise from the body, and then
max(.., RedeliveryCount).
Since 1.1.0, before decoding, the consumer version-gates on the bq-schema-version property
(as the Pulsar binding requires; 1.0.0 decodes the body
first and only rejects through the body check). A missing or blank property
(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 body. A "1"
property does not skip the body check: the decoded envelope still goes through the
schema_version validation, and a body that fails it is poison.
A rejected message is settled on its first delivery (ADR-0037, since 1.3.0), whatever bq-attempts,
RedeliveryCount or MaxAttempts say. OnError is notified with an empty envelope; no handler
and no OnUnknownUrn hook runs, and it is never retried or re-published.
| Configuration | Result |
|---|---|
DeadLetterProducer set |
the raw body, byte for byte, with no dead_letter block and no poison wrapping, is written to <queue>.dlq and the message is then acknowledged |
no DeadLetterProducer |
acknowledged and dropped, with a trace warning |
the <queue>.dlq write fails |
negatively acknowledged and reported; nothing is acknowledged or retried, the redelivery is rejected again and the write is tried again |
Behaviour change (ADR-0037, since 1.3.0). Until now a version-gated message took the bounded poison route: a bounded retry while no
DeadLetterProducerwas set, the poison record while one was, spending up toMaxAttemptsdeliveries on a message that can never succeed. It no longer does. This consumer has no unknown-URN strategy option, so it behaves asfail.
OnError and OnRoutingMismatch are synchronous Action delegates (OnUnknownUrn returns a
Task and may be async). Do not assign an async lambda to them: it compiles to async void, so an exception thrown after its
first await escapes the consumer’s try/catch and can crash the process. Keep hooks
synchronous, or hand async work off yourself and observe its failures.
Contract mapping (§5)
| Envelope | Apache Pulsar |
|---|---|
| body | message payload (byte-identical across SDKs) |
job (URN) |
property bq-job — route on this property; body job / urn only if absent or blank (§5.2, §5.7) |
trace_id |
property bq-trace-id |
meta.id |
property bq-message-id |
meta.schema_version |
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 |
property bq-source-lang |
meta.created_at |
PublishTime (informational mirror; the body’s meta.created_at is authoritative) |
attempts |
property bq-attempts — authoritative when it matches ^[0-9]+$ and fits an int (else the body’s value), cross-checked against RedeliveryCount (§5.5) |
| reserve / ack / retry | Acknowledge / re-publish with bq-attempts + 1 then Acknowledge (a negative acknowledgement only as the Shared / Key_Shared fallback) |
Pulsar properties are string→string, so integers are stringified. Per the contract, bq-job
carries the URN so a consumer can route on a property rather than the body’s job, and
bq-attempts is the authoritative retry counter; the native RedeliveryCount is read as a
cross-check: the handler sees max(<bq-attempts or body attempts>, RedeliveryCount).
RedeliveryCount is 0-based, so it maps directly with no −1. The DotPulsar IProducer
/ IConsumer / IMessage interfaces are mockable, so the unit tests use Moq — no Pulsar,
no network.
The envelope is unchanged (schema_version stays 1); Apache Pulsar is purely additive.