Broker bindings
Last updated:
The envelope is broker-agnostic — it is just the message body. A binding says how a given broker carries that body natively, so a consumer can route by URN and continue a trace without decoding the body, and so a message one SDK produces is consumed byte-for-byte by another over the same broker.
Two rules hold for every binding:
- The body is always the canonical envelope — compact UTF-8 JSON, byte-identical across SDKs. Native metadata is a redundant, routable projection of the body, never a replacement for it.
- The envelope is frozen at
schema_version: 1. A new broker is purely additive — it never changes the wire format.
Redis
Redis lists carry no per-message metadata, so routing and tracing read the envelope body
directly (job/urn, trace_id). Delivery is at-least-once in both layouts. The binding
has two key layouts:
- Zset layout (canonical). Laravel’s own layout:
queues:<name>(ready list),queues:<name>:delayed,queues:<name>:reservedandqueues:<name>:notify. - Legacy reliable-queue layout.
<name>(ready list),<name>:processingand<name>:processing:deadlines. It stays the default in every SDK that defaults to it today.
Zset layout
The Laravel driver speaks this layout natively. The five SDKs that have a Redis transport offer it as an opt-in; the legacy layout stays the default:
| SDK | Opt-in |
|---|---|
| Go | redis.WithLaravelLayout(prefix) |
| Python | laravel_compat=True (URL ?laravel=1) |
| Node | layout: "zset" |
| Java | .layout(RedisLayout.ZSET) on the publisher and consumer builders |
| .NET | Layout = RedisLayout.Zset on RedisConsumerOptions and RedisPublisherOptions |
- Reserve is Laravel’s pop Lua, wrapped in
pcall: pop fromqueues:<name>, add one toattempts, store the member in:reservedscored by theretry_afterdeadline. The handler sees the raw job, and the attempts value it gets isjob.attempts + 1. A body that does not decode is written to the DLQ as poison instead of being lost. - Identity-less body (both layouts). A body that decodes but has neither a valid
jobnor a validurnis poison on Redis too. It is dead-lettered as a poison record (reason: "poison", original body kept) in every configuration:on_unknown_urn: deleteand “dead-lettering off” change nothing, the handler never runs and it is never retried. It does not take the unknown-URN path, which stays for a valid envelope whose URN has no handler. Go, Python, Java, .NET and Node follow this rule always, and the Laravel driver follows it with its DLQ enabled (see the Laravel exception in the second bullet after this one). - A DLQ opt-out never covers poison. A setting that turns the DLQ off, such as Node
@babelqueue/redisdeadLetterQueue: null, disables only the dead-letter of an exhausted message and of an unknown URN (on_unknown_urn: dead_letterthen degrades todelete). A poison body (invalid UTF-8, a reserve{job, job}, an identity-less object) is still dead-lettered as the poison record, to the configured DLQ name, and withnullto the default<queue>.dlq. Before this rule Node dropped such a body without a record; it now matches Go and Python. - Laravel exception. On Laravel’s
babelqueue-*connections with the BabelQueue DLQ disabled, a poison body and a job whose retries are exhausted are failed into Laravel’s ownfailed_jobstable instead of being dead-lettered, sophp artisan queue:failedandqueue:retrykeep working as they do for any Laravel job. With the DLQ enabled the poison body is dead-lettered as the poison record. This is a deliberate exception to “every configuration” above: Laravel’s native failure handling stays where you have not opted into the BabelQueue DLQ. - A failed DLQ write leaves the member in
:reserved. When writing the DLQ record of a reservation throws (poison, exhausted retries oron_unknown_urn: dead_letter), the SDK neither acks nor releases it. Theretry_afterrecovery moves it back to the ready list (counting an attempt) and the next delivery tries the DLQ write again. Laravel follows the same rule: a DLQ publish exception no longer reachesdeleteAndRelease, so the member is not moved to:delayed. Withretry_after: nullthe member stays in:reserveduntil an operator acts. - Ack is
ZREMon:reserved. Release is one Lua script (ZREM :reservedplusZADD :delayed) with no push-and-ack fallback, so a crash cannot duplicate or drop the message. - This layout carries no
__bq_framewrapper and notraceparentheader.trace_idstays in the envelope.
Delays (zset layout)
Delays are normative for the zset layout only. The legacy layout has no delayed set: a release goes straight back to the ready list, and a delayed publish is rejected in every SDK, while a release delay is ignored (Python, Go, .NET) or rejected (Java). Use the zset layout when you need a delay.
A delayed message goes to queues:<name>:delayed with the score
floor(now) + ceil(delay): epoch seconds from the producer’s clock, so a sub-second delay
rounds up. Every pop migrates first, :delayed and then :reserved, in chunks of 100;
migration_batch_size defaults to -1 (no limit). With no worker running, nothing
migrates.
- A delayed publish writes no token to
:notify, so it does not wake a blocked consumer. A blocking reserve waits at most 1 s per slice and then migrates again. - On one host, a message published with
delay = 2sis received between 1.0 s and 3.5 s later. - The score uses the producer’s clock and the migrate uses the consumer’s. A producer
that runs
sseconds ahead deliverssseconds late. Run NTP on every host.
Recovery and retry_after
retry_after defaults to 90 seconds. null disables :reserved recovery, and a
negative value is a configuration error. Keep the handler timeout shorter than
retry_after, or a message that is still being processed is recovered and processed twice.
- Zset layout. A
:reservedmember whose deadline has passed goes back to the ready list during the next migrate. The recovered member carriesattempts + 1, so a recovery counts as an attempt. This path re-encodes the job with Redis’s cjson, which can turn[]into{}, reorder keys and print long integers in exponent notation. It is the one deliberate exception to byte-preserving re-emit. - Legacy layout. Python, Go, Node, Java and .NET run a sweep before every reserve. Each
reservation also writes a member
<token>:<raw>(a 32-character lowercase hex token from a CSPRNG, then the raw body) to<name>:processing:deadlines, scoredfloor(now) + retry_after. The sweep moves every expired body from:processingback to the ready list, byte for byte, and removes its member. A legacy recovery does not incrementattempts, so a message that crashes its consumer every time is redelivered forever; the zset layout is the way out. - Ack, release and DLQ first
ZREMthe deadline member. If that returns 0, the reservation already expired and the SDK touches nothing and logs “reservation expired”. - An item on
:processingwith no deadline member (for example one reserved by an older consumer) is never touched. - The legacy reserve is non-blocking and sleep-polls: 100 ms, doubling after every empty poll, capped at 1 s.
Legacy layout and retry_after: null
null is not a switch that turns the sweep off. The sweep still runs before every reserve.
A consumer configured with null writes its deadline member with the score +inf, and the
sweep never selects that member.
- If a consumer crashes while running with
null, its message stays on:processingand no sweep moves it. - After you change
retry_afterfromnullto a number, the existing+infmembers stay. Cleaning them up is a manual task.
Run the script below only while every consumer of that queue is stopped. It is a single
atomic EVAL: it finds each +inf member, takes its raw part (everything from the 34th
byte), moves the body back to the ready list only if it is still on :processing, and
removes the member. Do not replace it with separate redis-cli commands; a connection
that drops between them loses messages silently.
redis-cli EVAL "$(cat <<'LUA'
local members = redis.call('ZRANGEBYSCORE', KEYS[3], '+inf', '+inf')
for _, m in ipairs(members) do
local raw = string.sub(m, 34)
if redis.call('LREM', KEYS[2], 1, raw) == 1 then
redis.call('RPUSH', KEYS[1], raw)
end
redis.call('ZREM', KEYS[3], m)
end
return #members
LUA
)" 3 orders orders:processing orders:processing:deadlines
Replace orders with your queue name. The script returns the number of members it
handled. It runs inside Redis in one step, so keep the deadlines set small enough for that
to be quick. On Redis Cluster the three keys must share a hash tag, so call it with
3 {orders} {orders}:processing {orders}:processing:deadlines.
Unknown URN on Java and .NET
The Java and .NET Redis consumers have no unknown-URN strategy option. A valid envelope whose
URN has no handler is dead-lettered with a fixed dead_letter (reason: "unknown_urn") to
<queue>.dlq and leaves the reserved set in the same atomic step, instead of the generic
fail default. A registered unknown-URN hook takes precedence: it runs, the message is acked and
no DLQ record is written. In these two consumers the DLQ write is unconditional: there is no
DLQ opt-in switch, so it never degrades to delete, and <queue>.dlq grows until an operator
drains it. The Go, Python, Node and Laravel Redis transports follow the configured
on_unknown_urn strategy; their default stays fail. The reason is Redis-specific: Redis has no
broker-side redelivery limit, so a fail that only reports the error would leave the job to a
permanent reserve/recover loop.
Redis compatibility
The reservation step is not the same command in every SDK, so the minimum Redis version differs.
| SDK / transport | Legacy reservation | Minimum Redis | Redis Cluster |
|---|---|---|---|
Go (babelqueue-go/redis) |
Lua script with LMOVE <queue> <queue>:processing LEFT RIGHT |
6.2 | Only with hash-tagged queue names |
Python (babelqueue[redis]) |
Same Lua script | 6.2 | Only with hash-tagged queue names |
Java (babelqueue-redis) |
Same Lua script | 6.2 | Only with hash-tagged queue names |
.NET (BabelQueue.Redis) |
Same Lua script | 6.2 | Only with hash-tagged queue names |
Node (@babelqueue/redis) |
Same Lua script (it used BRPOPLPUSH before) |
6.2 | Only with hash-tagged queue names |
| Laravel driver | Laravel’s own Lua-script reservation (:reserved sorted set) |
Set by Laravel | Only with hash-tagged queue names (queues:{name}, :reserved, :delayed) |
- Pop side. Node used to pop with
BRPOPLPUSH, which isBLMOVE … RIGHT LEFT: it took the most recently pushed element (LIFO). Node now pops from the head like the other four SDKs, so the legacy layout is FIFO everywhere. Redis listsLMOVEandBLMOVEas available since 6.2.0 andBLMOVEas the replacement for the deprecatedBRPOPLPUSH(BLMOVE). - Cluster. A script that touches several keys only works when all of them sit in one
hash slot. Put a hash tag in the queue name (for example
{default}) and use the same name in every producer and consumer, in every language. Choosing the tag is your responsibility: the SDK uses the name as given and neither adds nor removes braces. Without a tag, Redis’sCROSSSLOTerror surfaces (Redis Cluster specification). - At-least-once. An early sweep (a consumer clock running ahead) or an ack after the deadline causes a second processing, so handlers must be idempotent.
RabbitMQ (AMQP 0-9-1)
The body is the envelope; the contract is also projected onto AMQP properties and headers so a consumer can route on the broker’s metadata:
| AMQP field | Carries |
|---|---|
type |
the URN (job) |
correlation_id |
trace_id |
message_id |
meta.id |
header x-attempts |
attempts |
header x-schema-version |
meta.schema_version |
header x-source-lang |
meta.lang |
Messages are application/json, persistent delivery, on durable queues; consumed with
basic.get + manual ack (at-least-once).
Amazon SQS
The canonical envelope is the MessageBody. On produce, the transport projects the
envelope onto native MessageAttributes — a routable view of the body. Ids and
strings are DataType String; counters are DataType Number:
| MessageAttribute | DataType | Value |
|---|---|---|
bq-job |
String | the URN (job) |
bq-trace-id |
String | trace_id |
bq-message-id |
String | meta.id |
bq-schema-version |
Number | meta.schema_version (1) |
bq-source-lang |
String | meta.lang |
bq-created-at |
Number | meta.created_at as epoch milliseconds |
Consuming uses SQS’s native delivery semantics:
- A receive reserves the message for the visibility timeout; a successfully handled
message is removed with
DeleteMessage. A failing handler is released, not deleted, while retries remain: every SDK consumer callsChangeMessageVisibilityon the message with the configured release delay, which defaults to 0 s (redeliver now), so retries are at-least-once and do not wait out the visibility timeout. An unknown URN under thereleasestrategy uses the same call, also with a 0 s default. Runtimes with a max-attempts policy (the Go and PythonApp, default 3) stop releasing once attempts are exhausted: the message is written to the BabelQueue dead-letter queue (<queue>-dlqon SQS) when dead-lettering is enabled, otherwise dropped, and in both cases deleted from the SQS queue. - DLQ name. The BabelQueue dead-letter queue on SQS is
<queue>-dlq(FIFO:<queue>-dlq.fifo), not the<queue>.dlqof the other brokers: an SQS queue name cannot contain a dot. The Java, .NET and Node consumers take the queue URL you configure; the Go (sqsv1.12.0) and Python (1.24.0) SQS transports and the Laravel driver (1.10.0) derive<queue>-dlqthemselves by default (an explicit queue or suffix setting overrides it). On a FIFO queue the name keeps.fifolast: an explicit suffix goes before it (orders.fifo+-dlq→orders-dlq.fifo). The envelope on the wire is unchanged. - Poison messages and the version gate. A body that does not decode into an envelope
(including an identity-less JSON object), and a message whose
bq-schema-versionattribute is present, not blank and not"1", are settled on the first delivery: never handed to a handler and never retried by the SDK. Which settle depends on the configuration:on_unknown_urn: delete→DeleteMessage(dropped).- Dead-lettering on (a DLQ queue URL) → a poison body is written to
<queue>-dlqas the poison record (reason: "poison", the original bytes kept); a version-gated message is written there as the raw body, byte for byte, with nodead_letterblock. Only then is the original deleted. A failed write leaves it undeleted, so visibility expiry redelivers it, it is rejected again and the write is retried. - Dead-lettering off → the SDK leaves it to SQS: no
DeleteMessage, the failure is reported, and visibility expiry redelivers it. The queue’s ownRedrivePolicy(maxReceiveCount→ a dead-letter queue) moves it to the native DLQ; without one it is redelivered until the retention period ends, so configure aRedrivePolicyon every consumed queue. The Go and Python runtimes, which used to retry a poison body up to their attempt limit and then drop it, hand it to SQS the same way. With a DLQ queue URL configured, leaving a poison or version-gated message to theRedrivePolicyis not a conformant settle. Only an explicitly configuredon_unknown_urnstrategy is read (failwhen none is set); anonUnknownUrnhook, or a strategy it implies, never applies, and thedead_letterstrategy without a DLQ queue URL behaves as dead-lettering off. Every SDK consumer except the Laravel driver follows this (the Laravel driver keeps its own path; see the Laravel exception under Redis). For a handler that keeps failing, set a non-zero release delay as backoff.
- Version gate. Before decoding the body, the consumer checks the
bq-schema-versionmessage attribute. If it is absent or blank, fall through to the normal decode path; for any value other than"1", reject the message without decoding it and settle it as above — do not best-effort parse. A"1"attribute still goes through the body’smeta.schema_versioncheck after decoding, and a body that fails that check is poison. attemptsis reconciled tomax(body.attempts, ApproximateReceiveCount − 1). The first delivery reads0; a runtime-incremented count is never lowered; an absent or non-numericApproximateReceiveCountis ignored. A drop-in driver that instead surfaces the broker’s native count (e.g. Laravel’sSqsJob::attempts()) documents that divergence.
FIFO queues (.fifo) set MessageGroupId (the configured group, else the queue
name) and MessageDeduplicationId = meta.id — unless the queue uses content-based
deduplication. Delayed delivery uses DelaySeconds, capped at SQS’s 900-second maximum.
This binding is implemented identically across every SDK that ships an SQS transport
(Go, Python, Node, Java, PHP, .NET) and is locked by the
conformance suite, so the projected
attributes and the reconciled attempts are guaranteed to match.
Azure Service Bus
The canonical envelope is the Body. Azure Service Bus has first-class native slots
for almost every envelope concept, so the binding maps onto native message fields and
needs only two custom application properties:
| Field | Value |
|---|---|
Subject (a.k.a. Label) |
the URN (job) — route on Subject without reading the body |
CorrelationId |
trace_id |
MessageId |
meta.id (enables ASB duplicate detection) |
ContentType |
application/json |
DeliveryCount |
broker-maintained, 1-based — the native attempts source |
ApplicationProperties["bq-schema-version"] |
meta.schema_version (1) |
ApplicationProperties["bq-source-lang"] |
meta.lang |
ApplicationProperties["bq-created-at"] |
meta.created_at (epoch ms, convenience mirror) |
Application properties are native AMQP-typed values (numbers stay numbers), not the
DataType-wrapped strings SQS uses.
Consuming uses ASB’s PeekLock model:
- A receive reserves the message for the lock duration; a handled message is removed
with
Complete. A failing handlerAbandons it — the broker redelivers and incrementsDeliveryCount(at-least-once). AtMaxDeliveryCountASB auto-moves it to the native$DeadLetterQueuesub-queue. - Poison messages and the version gate. A body that does not decode into an envelope
(including an identity-less JSON object), and a message whose
bq-schema-versionapplication property is version-gated (below), are settled on the first delivery: never handed to a handler and never retried by the SDK. Which settle depends on the configuration:on_unknown_urn: delete(where the consumer has the option) →Complete(dropped).- Dead-lettering on (a
<queue>.dlqsender) → a poison body is written to<queue>.dlqas the poison record (reason: "poison", the original bytes kept); a version-gated message is written there as the raw body, byte for byte, with nodead_letterblock. Only then is the original completed. A failed writeAbandons it (redelivered, rejected again, the write retried) and is reported. - Dead-lettering off → the SDK leaves it to Service Bus: an explicit
DeadLetter(...)into$DeadLetterQueue(reasonunsupported schema versionfor the version gate), orAbandonuntilMaxDeliveryCountmoves it there. Both are conformant, the body stays unchanged and the rejection is reported. The Go and Python runtimes, which used to retry a poison body up to their attempt limit and then drop it, hand it to Service Bus the same way. With a<queue>.dlqsender configured, abandoning towards the native sub-queue is not a conformant settle. The<queue>.dlqis an ordinary Service Bus queue and is opt-in in every SDK consumer, so a polyglot reader has one place to look. These consumers have no unknown-URN strategy option in Java, .NET and Node (they behave asfail); where a strategy exists only the explicitly configured one is read, anonUnknownUrnhook never applies, anddead_letterwithout a sender behaves as dead-lettering off.
- Version gate. Before decoding the body, the consumer checks
ApplicationProperties["bq-schema-version"]. If it is absent or blank, fall through to the normal decode path; for any value other than"1", reject the message without decoding it and settle it as above — do not best-effort parse. The property is AMQP-typed: integral1(any width) or the string"1"passes, while a floating-point1.0is not integral1and is rejected. The one exception is a runtime whose AMQP client cannot tell a float1.0from an integer1(JavaScript, where both arrive as the number1): such a consumer may accept1.0as1. A property present with a null value is treated as absent and falls through to decode. A"1"property still goes through the body’smeta.schema_versioncheck after decoding, and a body that fails that check is poison. attemptsis reconciled tomax(body.attempts, DeliveryCount − 1):DeliveryCount(1-based) is the native floor (first delivery reads0), and a runtime-incremented body count is never lowered. The rule is identical for the native-consumer SDKs (.NET, Java, Node) and the runtime-transport SDKs (Python, Go).
Delayed delivery is native — set ScheduledEnqueueTime to now + delay. Auth is a
connection string or Azure AD (DefaultAzureCredential); transport is AMQP 1.0 over TLS
(or WebSockets). An optional SessionId (FIFO) is opt-in and not a contract field.
This binding is implemented identically across every SDK that ships an ASB transport (.NET, Java, Python, Node, Go) and is locked by the conformance suite. PHP is deferred (no modern official client).
Apache Pulsar
The canonical envelope is the message payload. Pulsar message properties are
string→string, so the binding projects a redundant, routable view of the body onto
bq- properties (every value stringified) and keeps the body authoritative:
| Property | Value |
|---|---|
bq-job |
the URN (job) — route on bq-job without reading the body |
bq-trace-id |
trace_id |
bq-message-id |
meta.id |
bq-schema-version |
meta.schema_version ("1") |
bq-source-lang |
meta.lang |
bq-attempts |
attempts — the authoritative count, carried in the body |
publishTime |
mirrors meta.created_at (broker-set; body authoritative) |
Properties are strings (Pulsar has no typed properties), so numbers are stringified — unlike ASB’s native AMQP-typed values.
Consuming receives one message at a time:
-
A handled message is
acknowledged. Redelivery is bounded by the SDK, not by the broker: Pulsar has no broker-side redelivery limit, and the SDKs set no nativeDeadLetterPolicy(one you add yourself is a second, uncoordinated limit). Every consumer takes a max-attempts setting, default 3: the total number of deliveries a message gets, the first included (1means no retry). The Go and Python runtimes, the Java, .NET and Node consumers and the PHPConsume\Dispatcherall follow the same rule, locked by thepulsar_exhaustionconformance section. -
Counted attempt. On each delivery the SDK takes the reconciled attempts (below) and adds one for the current delivery. While the counted attempt is below max-attempts, a failing handler (or an unmapped URN without an
onUnknownUrnhook) is retried. At the cap the message is dead-lettered to<queue>.dlqwhen dead-lettering is on, and acknowledged, dropped and logged when it is off.dead_letter.attemptsis the counted value: with the default of 3 it is3. -
A retry is a re-publish, not a nack. The SDK re-publishes the message to the same topic with
bq-attempts + 1(the body’sattemptskept in sync) and then acknowledges the original, but only after the publish succeeded; a failed publish leaves the original negatively acknowledged, so nothing is lost. A negative acknowledgement advances Pulsar’s redelivery count only onSharedandKey_Sharedsubscriptions, so it cannot be the bound onExclusive(the client default) orFailover. Because the count travels in the message, the cap holds on every subscription type. -
The retry producer is built for you. The Java, .NET, Node and PHP consumers create the re-publish producer from the client or connection they already hold; a producer you pass in is used instead. If none can be built, a
SharedorKey_Sharedconsumer falls back to the nack (the redelivery count advances there). AnExclusiveorFailoverconsumer without a producer fails at setup with a configuration error, and so does a consumer whose subscription type is not known and that was given neither client nor producer (it is treated asExclusive). Pass theclient, or declare the subscription asShared/Key_Shared. -
Poison never reaches a handler. A body that does not decode into an envelope, including a JSON object with neither a valid
jobnor a validurn, takes the poison route. With dead-lettering on it is written to<queue>.dlqat once, without retry, as a poison record (reason: "poison", the original bytes kept: an object body gets adead_lettermember, anything else is wrapped underraw, as base64 plusraw_encodingwhen it is not valid UTF-8). With dead-lettering off it takes the bounded retry above and is then dropped.on_unknown_urn: delete(Go, Python, PHP) acknowledges and drops it. This is the Go runtime’s behaviour, now shared by Python (whose Pulsar poison record carriesreason: "poison"rather thanunknown_urn), Java, .NET (including a body it used to reject before dispatch) and Node. -
The DLQ write is acknowledged last. The original is acknowledged only after the
<queue>.dlqwrite succeeded; a failed write leaves it for redelivery. -
Routing is preserved. The retry copy and the DLQ record keep the
bq-jobroute the delivery arrived on, so the next delivery reaches the same handler. -
PHP
Consume\Dispatcher. Dead-lettering is on when aDeadLetterPublisheris set. Every record it writes (failed,unknown_urn,poison) goes to<queue>.dlq, with the queue taken from the consumer’s topic, and never todefault.dlq; when the topic is unknown the message is acknowledged, dropped and logged.on_unknown_urn: failandreleaseare bounded bymaxAttemptslike a failing handler, and its default is now3(0still means no cap). Without a publisher, a message that reaches the cap is acknowledged, dropped and logged on Pulsar; on Kafka theDispatchernever drops, it rethrows so the retry router decides. The barePulsarConsumerwithout theDispatcherleaves the decision to your handler. -
Version gate. A message whose
bq-schema-versionproperty is present, not blank and not"1"is rejected without decoding the body and settled on its first delivery, whateverbq-attempts, the native redelivery count or the max-attempts setting say. It is outside both the max-attempts rule and the poison route: no handler and noonUnknownUrnhook runs, and it is never retried or re-published.on_unknown_urn: delete→ acknowledged (dropped).- Dead-lettering off and the strategy is not
dead_letter→ acknowledged, dropped and logged. - Otherwise → the raw body, byte for byte, written to
<queue>.dlqwith nodead_letterblock and no poison wrapping, and only then acknowledged. (An SDK in which dead-lettering off means no DLQ writer exists, such as the PHPDispatcherwithout aDeadLetterPublisher, cannot write it: thedead_letterstrategy then acknowledges, drops and logs it.) A reader recognises the record by the absence of adead_letterblock. - A failed DLQ write leaves it unsettled (negative acknowledgement): nothing is acknowledged or retried, the failure is reported, the redelivery is rejected again and the write is retried.
The Go and Python runtimes, the Java, .NET and Node consumers and the PHP
Consume\Dispatcherall follow this rule, locked by theversion_gate_pulsarconformance section. The Java, .NET and Node consumers have no unknown-URN strategy option and behave asfail; the gate reads only an explicitly configured strategy, so anonUnknownUrnhook never changes how a version-gated message is settled. A body that passes the property gate but carries an unsupportedmeta.schema_versionis rejected by the decoder and is poison, not version-gated. This replaces the previous behaviour, where Java and Node negatively acknowledged such a message without limit and .NET and PHP put it on the bounded retry. -
attemptsis reconciled tomax(body.attempts, RedeliveryCount).RedeliveryCountis 0-based (0 on first delivery), so it maps directly with no −1 — and a runtime-incremented body count is never lowered. The rule is identical for the native-consumer SDKs (.NET, Java, Node) and the runtime-transport SDKs (Python, Go).
Delayed delivery is native — deliverAfter (relative) or deliverAt (absolute), also
mirrored on a bq-delay property. The default subscription is Shared, named
babelqueue; topics default to persistent://public/default/<queue>. Auth is via the
service URL (pulsar:// or pulsar+ssl://) plus any client-configured TLS/token.
This binding is implemented identically across every SDK that ships a Pulsar transport
(.NET, Java, Python, Node, Go) and is locked by the
conformance suite. PHP reaches Pulsar over
Pulsar’s native WebSocket API (PulsarTransport to produce, PulsarConsumer to consume)
— the same envelope and bq- properties, just a different access path; it round-trips live
against the native SDKs.
Apache Kafka
Kafka is a partitioned, append-only log with consumer-group offset commits — not a
queue with per-message ack. It has no native per-message ack, delayed delivery,
dead-letter queue, or delivery counter, so this binding absorbs all four in the transport
layer (the envelope stays schema_version: 1). The canonical envelope is the record
value; the contract fields move to bq- record headers (UTF-8 byte strings, so
integers are stringified):
| Field | Value |
|---|---|
value |
the canonical envelope JSON |
timestamp |
mirrors meta.created_at (Unix ms) |
header bq-job |
the URN (job) — route on bq-job without reading the body |
header bq-trace-id |
trace_id |
header bq-message-id |
meta.id |
header bq-schema-version |
meta.schema_version ("1") |
header bq-source-lang |
meta.lang |
header bq-attempts |
attempts — the authoritative retry counter (Kafka has no native one) |
Consuming is process-then-commit (manual commit, enable.auto.commit = false): a
record is reserved by being polled, the handler runs, and only then is the offset committed
(Commit). A crash before commit redelivers it on the next poll — at-least-once; handlers
MUST be idempotent (dedupe on meta.id).
- Version gate. Version-gate on the
bq-schema-versionheader before decoding the body. If the header is absent or blank (a header present with a null value counts as absent), fall through to the normal decode path; for any value other than"1", reject/quarantine the record without decoding it (§6.5 poison/DLQ path) — do not best-effort parse. Then decode the record value as the canonical envelope JSON (UTF-8; accept theurnalias forjob), where the body’smeta.schema_versioncheck still applies. attemptsis reconciled to thebq-attemptsheader when present (authoritative), else the body’s ownattempts(the fallback for a non-BabelQueue producer). This is not a max — the header overrides the body.- Retry / delay is SDK-owned, since Kafka has neither. See
Kafka retry topics below. A delay or release with no retry
tiers configured raises
BabelQueueExceptionrather than silently dropping. - Terminal failure → DLQ: at max-tries the envelope goes to
<topic>.dlqwith the additivedead_letterblock (opt-in; if disabled, terminal failures degrade to commit-and-drop). - Poison never blocks the partition. A poison record (a body that does not decode, or a
JSON object with no valid identity) never reaches a handler. In Java, .NET, Node and the PHP
KafkaConsumer, with no DLQ configured it is committed, dropped and logged, whateveron_unknown_urnsays. The Go and Python runtimes follow the setting-dependent poison route instead: with dead-lettering on (andon_unknown_urnnotdelete) the record is written to<topic>.dlqwithout retry; with it off it takes a bounded retry through the retry tiers up tomax_attempts, then is dropped. Python with no retry tiers (retry_tiers_msunset) has no tier to retry on: it used to raise and leave the partition blocked, and now commits, drops and logs the record. A failing handler or areleaseof a valid envelope without tiers still raises. - PHP
Consume\DispatcheroverKafkaConsumer. With aDeadLetterPublishera record is dead-lettered to<topic>.dlq,<topic>being the record’s source topic (else the topic the consumer is subscribed to). When the topic is still ambiguous the record is committed, dropped and logged. Without a publisher theDispatchernever drops a Kafka record: it rethrows, so aKafkaRetryRouter(or the consumer) decides. TheDispatcher’s defaultmaxAttemptsof 3 means aDispatcherwith a publisher dead-letters before a router with its default of 5; passmaxAttempts: 0to leave the cap to the router. - Record key on republish. The retry-topic republish (§6.5), the retry-topic → work-topic re-inject (§6.4), both DLQ writes (envelope and raw/poison) and the DLQ redrive (ADR-0026) — both its write back to the work topic and every restore write back to the DLQ (dry run, unselected, undecodable, failed redrive write) — SHOULD carry the consumed record’s key byte-for-byte (a null key stays null, an empty key stays empty). The key still carries no contract meaning (§6.1); partition equality across SDKs is not guaranteed, because client partitioners differ.
Connection is bootstrap.servers + a group.id; auth is SASL_SSL / SSL via the
native client. This binding is implemented identically across Java, Go, Node, Python and .NET
and is locked by the conformance suite. PHP
ships both halves over ext-rdkafka (opt-in — the one binding that relaxes the
zero-extension rule): KafkaTransport to produce, KafkaConsumer (process-then-commit) to
consume, plus the SDK-owned retry-topic machinery (KafkaRetryRouter + KafkaRetryConsumer);
proven live Java→PHP.
Kafka retry topics
Retry topics are named <topic>.retry.<n>, where n is the tier index, starting at 1.
Each tier has a delay in milliseconds. The recommended tiers are
[5000, 30000, 300000, 1800000] (5 s, 30 s, 5 min, 30 min), exposed as
DEFAULT_RETRY_TIERS_MS. They are opt-in: configure them explicitly.
Retry and delay pick a tier by two different rules. Do not merge them.
| Operation | Tier rule |
|---|---|
| Retry after a handler failure | n = min(attempts_before, N - 1) + 1, where N is the number of tiers. A record that exhausts the tiers stays on the last one. |
| Explicit delay | The smallest tier whose delay is at least the requested delay. A delay larger than the last tier raises; nothing is clamped. |
A retry republish writes the envelope to the tier topic with bq-attempts = attempts_before + 1,
bq-delay = <tier delay in ms>, bq-original-topic = <work topic> and the record key byte for
byte, then commits the original offset.
Re-inject consumer. Every SDK that ships tiers also ships a consumer for the retry topics. It is mandatory: without it, retried messages sit on the tier topics forever. For each record it:
- waits until
record.timestamp + tier_ms, clamped to[now, now + tier_ms], by pausing the partition instead of blocking the poll loop; - writes the value and key byte for byte to
bq-original-topic, dropsbq-delayandbq-original-topic, and keeps every other header (traceparentincluded); - commits the retry-topic offset only after that write is acknowledged.
Create retry topics with message.timestamp.type=LogAppendTime, so the wait is measured from
when the broker received the record, and set retention longer than the largest tier.
The re-inject consumer does not decode the value: an undecodable record is forwarded to the
work topic as is. Poison handling belongs to the work-topic consumer, which sends a record
that carries no identity to <topic>.dlq with reason: poison.
Kafka does not apply retry_backoff: the tier decides the
delay.
Upgrading from an older php-sdk. Earlier php-sdk releases named retry topics
<topic>.retry.<delayMs>. Drain those topics before you upgrade: the new
<topic>.retry.<n> names are tier indexes, and a record left on an old topic is not
re-injected. In Go, PublishRetryForAttempt is the new tier-aware call and PublishRetry
is deprecated.
Apache ActiveMQ Artemis
Artemis speaks AMQP 1.0 (not RabbitMQ’s 0-9-1), and gives the binding native primitives —
per-message settlement, scheduled delivery, a delivery counter and a dead-letter address — so
it maps onto them rather than re-implementing. The canonical envelope is the message body;
the contract fields ride the slots a JMS peer reads, plus the string-valued bq_ application
properties:
| Field | Value |
|---|---|
| body | the canonical envelope JSON |
annotation x-opt-jms-type |
the URN (job) → JMSType — route on it without reading the body |
correlation-id |
trace_id → JMSCorrelationID |
creation-time |
meta.created_at (Unix ms) → JMSTimestamp |
property bq_schema_version |
meta.schema_version ("1") |
property bq_source_lang |
meta.lang |
property bq_attempts |
attempts — a 0-based mirror (the body stays authoritative) |
property bq_app_id |
"babelqueue" |
These property names use underscores, not the hyphens of the Kafka/Pulsar bindings: a JMS
property name must be a valid Java identifier (no -), and the Java binding produces/consumes
over JMS, so every Artemis SDK uses the same JMS-legal bq_ form for cross-protocol parity.
Unlike Kafka/Pulsar, the URN / trace_id / message-id are not bq_ properties — they ride
the JMS-native slots (x-opt-jms-type, correlation-id, the broker-set message-id), so a plain
JMS consumer routes and correlates with no BabelQueue awareness.
Consuming reserves one message at a time and settles it per message:
-
A handled message is
accepted (acknowledged). A failing handlerreleases it — the broker redelivers it (at-least-once) and increments the AMQPdelivery-count. At max-tries the envelope goes to the cross-language<queue>.dlqwith the additivedead_letterblock (alongside Artemis’s own dead-letter address). -
Poison messages and the version gate. A body that does not decode into an envelope (including an identity-less JSON object), and a message whose
bq_schema_versionproperty is version-gated (below), are settled on the first delivery: never handed to a handler and never retried by the SDK. The same applies over JMS and over AMQP 1.0.on_unknown_urn: delete(where the consumer has the option) → acknowledged, no record.- Dead-lettering on (a DLQ sender) → a poison body is written to
<queue>.dlqas the poison record (reason: "poison": an object body gets adead_lettermember, anything else is wrapped underraw); a version-gated message is written there as the raw body, byte for byte, with nodead_letterblock. Only then is the original acknowledged (JMSacknowledge()/ AMQPaccepted). - Dead-lettering off → acknowledged and logged (dropped). Artemis’s own
max-delivery-attemptsis not involved. - A failed DLQ write leaves it unsettled (JMS
session.recover(), AMQPreleased) and is reported; the redelivery is rejected again and the write is retried.
Only an explicitly configured
on_unknown_urnstrategy is read (failwhen none is set); anonUnknownUrnhook, or a strategy it implies, never applies, anddead_letterwithout a DLQ sender behaves as dead-lettering off.The Java, .NET and Node consumers write the poison record (they used to forward the raw body of a poison message, or acknowledge and drop it when no DLQ was set); the Go and Python runtimes take their configurable poison route. A reader of
<queue>.dlqtherefore must not assume every record is a canonical envelope. The Laravelbabelqueue-artemisdriver keeps its own version-gate path (see the Laravel exception under Redis), which is unchanged. -
Version gate. Before decoding the body, the consumer checks the
bq_schema_versionproperty. If it is absent or blank (a property present with a null value counts as absent), fall through to the normal decode path; for any value other than"1", reject the message without decoding it and settle it as above — do not best-effort parse. A"1"property still goes through the body’smeta.schema_versioncheck after decoding, and a body that fails that check is poison. Locked by theversion_gate_artemisconformance section. -
attemptsis reconciled tomax(body.attempts, delivery-count). The AMQPdelivery-countis 0-based (0 on first delivery), so it maps directly with no −1 — and a runtime-incremented body count is never lowered. The Java binding consumes over JMS and reads the 1-basedJMSXDeliveryCount, subtracting 1 to arrive at the same 0-basedattempts. The rule is identical for the native-consumer SDKs (.NET, Java, Node) and the runtime-transport SDKs (Python, Go).
Delayed delivery is native — JMS 2.0 setDeliveryDelay (Java) or the x-opt-delivery-time
annotation (AMQP), also mirrored on a bq_delay property. Connection is the broker’s AMQP
acceptor (amqp:// / amqps://, default port 5672); Java connects over JMS via the Artemis
client. This binding is implemented identically across Java (JMS), .NET, Python, Node and Go (all
AMQP 1.0) and is locked by the
conformance suite. PHP reaches Artemis over
STOMP (StompTransport to produce; Laravel ships the babelqueue-artemis drop-in consume
driver) — Artemis bridges STOMP ↔ AMQP 1.0 ↔ JMS on the same address, so a STOMP-produced
message is consumed natively by the JMS and AMQP-1.0 SDKs, and vice versa.
Retry backoff reference
SDKs that retry a failed message by releasing it back to the broker with a delay compute
that delay from one formula. For the n-th delivery (n = attempts + 1):
d(n) = min(max, base * multiplier^(n - 1))
| Parameter | Default | Meaning |
|---|---|---|
retry_backoff |
0 |
base, the first delay, in seconds (not milliseconds, unlike Kafka tiers). 0 keeps today’s behaviour. |
retry_backoff_multiplier |
1 |
Growth factor per attempt. 1 gives a constant delay. |
retry_backoff_max |
0 |
Upper bound on d(n), in seconds. 0 means no cap. SDKs clamp a delay to SQS’s 12 hour limit (43200 s). |
retry_jitter |
none |
none or full. full picks a uniform random delay between 0 and d(n). |
The defaults keep today’s behaviour, so upgrading changes nothing until you set a value. The Node, Java and .NET SQS transports expose the same
parameters as releaseDelay* aliases.
- Redis legacy layout ignores it. The legacy layout has no delayed set, so a failed message
goes straight back to the ready list whatever
retry_backoffsays. Use the zset layout. - Kafka ignores
retry_backoff. Its delay comes from the retry tier (Kafka retry topics). - Max attempts is configured separately and named differently per SDK (
max_attemptsormaxTries). Defaults differ too: 5 in the PHP Kafka router, 0 in the PHPDispatcher, 3 elsewhere.
Other brokers
With Amazon SQS, Azure Service Bus, Apache Pulsar, Apache Kafka and Apache ActiveMQ Artemis all
shipped, the binding catalogue covers every broker on the expansion roadmap. New brokers follow
the same shape — body identical, contract fields projected onto native metadata — and ship as
additive MINOR releases. The envelope stays schema_version: 1.