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>:reserved and queues:<name>:notify.
  • Legacy reliable-queue layout. <name> (ready list), <name>:processing and <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 from queues:<name>, add one to attempts, store the member in :reserved scored by the retry_after deadline. The handler sees the raw job, and the attempts value it gets is job.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 job nor a valid urn is poison on Redis too. It is dead-lettered as a poison record (reason: "poison", original body kept) in every configuration: on_unknown_urn: delete and “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/redis deadLetterQueue: null, disables only the dead-letter of an exhausted message and of an unknown URN (on_unknown_urn: dead_letter then degrades to delete). 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 with null to 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 own failed_jobs table instead of being dead-lettered, so php artisan queue:failed and queue:retry keep 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 or on_unknown_urn: dead_letter), the SDK neither acks nor releases it. The retry_after recovery 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 reaches deleteAndRelease, so the member is not moved to :delayed. With retry_after: null the member stays in :reserved until an operator acts.
  • Ack is ZREM on :reserved. Release is one Lua script (ZREM :reserved plus ZADD :delayed) with no push-and-ack fallback, so a crash cannot duplicate or drop the message.
  • This layout carries no __bq_frame wrapper and no traceparent header. trace_id stays 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 = 2s is 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 s seconds ahead delivers s seconds 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 :reserved member whose deadline has passed goes back to the ready list during the next migrate. The recovered member carries attempts + 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, scored floor(now) + retry_after. The sweep moves every expired body from :processing back to the ready list, byte for byte, and removes its member. A legacy recovery does not increment attempts, so a message that crashes its consumer every time is redelivered forever; the zset layout is the way out.
  • Ack, release and DLQ first ZREM the deadline member. If that returns 0, the reservation already expired and the SDK touches nothing and logs “reservation expired”.
  • An item on :processing with 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 :processing and no sweep moves it.
  • After you change retry_after from null to a number, the existing +inf members 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 is BLMOVE … 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 lists LMOVE and BLMOVE as available since 6.2.0 and BLMOVE as the replacement for the deprecated BRPOPLPUSH (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’s CROSSSLOT error 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 calls ChangeMessageVisibility on 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 the release strategy uses the same call, also with a 0 s default. Runtimes with a max-attempts policy (the Go and Python App, default 3) stop releasing once attempts are exhausted: the message is written to the BabelQueue dead-letter queue (<queue>-dlq on 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>.dlq of 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 (sqs v1.12.0) and Python (1.24.0) SQS transports and the Laravel driver (1.10.0) derive <queue>-dlq themselves by default (an explicit queue or suffix setting overrides it). On a FIFO queue the name keeps .fifo last: 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-version attribute 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>-dlq as the poison record (reason: "poison", the original bytes kept); a version-gated message is written there as the raw body, byte for byte, with no dead_letter block. 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 own RedrivePolicy (maxReceiveCount → a dead-letter queue) moves it to the native DLQ; without one it is redelivered until the retention period ends, so configure a RedrivePolicy on 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 the RedrivePolicy is not a conformant settle. Only an explicitly configured on_unknown_urn strategy is read (fail when none is set); an onUnknownUrn hook, or a strategy it implies, never applies, and the dead_letter strategy 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-version message 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’s meta.schema_version check after decoding, and a body that fails that check is poison.
  • attempts is reconciled to max(body.attempts, ApproximateReceiveCount − 1). The first delivery reads 0; a runtime-incremented count is never lowered; an absent or non-numeric ApproximateReceiveCount is ignored. A drop-in driver that instead surfaces the broker’s native count (e.g. Laravel’s SqsJob::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 handler Abandons it — the broker redelivers and increments DeliveryCount (at-least-once). At MaxDeliveryCount ASB auto-moves it to the native $DeadLetterQueue sub-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-version application 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>.dlq sender) → a poison body is written to <queue>.dlq as the poison record (reason: "poison", the original bytes kept); a version-gated message is written there as the raw body, byte for byte, with no dead_letter block. Only then is the original completed. A failed write Abandons 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 (reason unsupported schema version for the version gate), or Abandon until MaxDeliveryCount moves 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>.dlq sender configured, abandoning towards the native sub-queue is not a conformant settle. The <queue>.dlq is 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 as fail); where a strategy exists only the explicitly configured one is read, an onUnknownUrn hook never applies, and dead_letter without 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: integral 1 (any width) or the string "1" passes, while a floating-point 1.0 is not integral 1 and is rejected. The one exception is a runtime whose AMQP client cannot tell a float 1.0 from an integer 1 (JavaScript, where both arrive as the number 1): such a consumer may accept 1.0 as 1. A property present with a null value is treated as absent and falls through to decode. A "1" property still goes through the body’s meta.schema_version check after decoding, and a body that fails that check is poison.
  • attempts is reconciled to max(body.attempts, DeliveryCount − 1): DeliveryCount (1-based) is the native floor (first delivery reads 0), 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 native DeadLetterPolicy (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 (1 means no retry). The Go and Python runtimes, the Java, .NET and Node consumers and the PHP Consume\Dispatcher all follow the same rule, locked by the pulsar_exhaustion conformance 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 onUnknownUrn hook) is retried. At the cap the message is dead-lettered to <queue>.dlq when dead-lettering is on, and acknowledged, dropped and logged when it is off. dead_letter.attempts is the counted value: with the default of 3 it is 3.

  • 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’s attempts kept 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 on Shared and Key_Shared subscriptions, so it cannot be the bound on Exclusive (the client default) or Failover. 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 Shared or Key_Shared consumer falls back to the nack (the redelivery count advances there). An Exclusive or Failover consumer 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 as Exclusive). Pass the client, or declare the subscription as Shared / Key_Shared.

  • Poison never reaches a handler. A body that does not decode into an envelope, including a JSON object with neither a valid job nor a valid urn, takes the poison route. With dead-lettering on it is written to <queue>.dlq at once, without retry, as a poison record (reason: "poison", the original bytes kept: an object body gets a dead_letter member, anything else is wrapped under raw, as base64 plus raw_encoding when 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 carries reason: "poison" rather than unknown_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>.dlq write succeeded; a failed write leaves it for redelivery.

  • Routing is preserved. The retry copy and the DLQ record keep the bq-job route the delivery arrived on, so the next delivery reaches the same handler.

  • PHP Consume\Dispatcher. Dead-lettering is on when a DeadLetterPublisher is set. Every record it writes (failed, unknown_urn, poison) goes to <queue>.dlq, with the queue taken from the consumer’s topic, and never to default.dlq; when the topic is unknown the message is acknowledged, dropped and logged. on_unknown_urn: fail and release are bounded by maxAttempts like a failing handler, and its default is now 3 (0 still means no cap). Without a publisher, a message that reaches the cap is acknowledged, dropped and logged on Pulsar; on Kafka the Dispatcher never drops, it rethrows so the retry router decides. The bare PulsarConsumer without the Dispatcher leaves the decision to your handler.

  • Version gate. A message whose bq-schema-version property is present, not blank and not "1" is rejected without decoding the body and settled on its first delivery, whatever bq-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 no onUnknownUrn hook 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>.dlq with no dead_letter block and no poison wrapping, and only then acknowledged. (An SDK in which dead-lettering off means no DLQ writer exists, such as the PHP Dispatcher without a DeadLetterPublisher, cannot write it: the dead_letter strategy then acknowledges, drops and logs it.) A reader recognises the record by the absence of a dead_letter block.
    • 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\Dispatcher all follow this rule, locked by the version_gate_pulsar conformance section. The Java, .NET and Node consumers have no unknown-URN strategy option and behave as fail; the gate reads only an explicitly configured strategy, so an onUnknownUrn hook never changes how a version-gated message is settled. A body that passes the property gate but carries an unsupported meta.schema_version is 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.

  • attempts is reconciled to max(body.attempts, RedeliveryCount). RedeliveryCount is 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-version header 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 the urn alias for job), where the body’s meta.schema_version check still applies.
  • attempts is reconciled to the bq-attempts header when present (authoritative), else the body’s own attempts (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 BabelQueueException rather than silently dropping.
  • Terminal failure → DLQ: at max-tries the envelope goes to <topic>.dlq with the additive dead_letter block (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, whatever on_unknown_urn says. The Go and Python runtimes follow the setting-dependent poison route instead: with dead-lettering on (and on_unknown_urn not delete) the record is written to <topic>.dlq without retry; with it off it takes a bounded retry through the retry tiers up to max_attempts, then is dropped. Python with no retry tiers (retry_tiers_ms unset) 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 a release of a valid envelope without tiers still raises.
  • PHP Consume\Dispatcher over KafkaConsumer. With a DeadLetterPublisher a 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 the Dispatcher never drops a Kafka record: it rethrows, so a KafkaRetryRouter (or the consumer) decides. The Dispatcher’s default maxAttempts of 3 means a Dispatcher with a publisher dead-letters before a router with its default of 5; pass maxAttempts: 0 to 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:

  1. waits until record.timestamp + tier_ms, clamped to [now, now + tier_ms], by pausing the partition instead of blocking the poll loop;
  2. writes the value and key byte for byte to bq-original-topic, drops bq-delay and bq-original-topic, and keeps every other header (traceparent included);
  3. 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 handler releases it — the broker redelivers it (at-least-once) and increments the AMQP delivery-count. At max-tries the envelope goes to the cross-language <queue>.dlq with the additive dead_letter block (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_version property 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>.dlq as the poison record (reason: "poison": an object body gets a dead_letter member, anything else is wrapped under raw); a version-gated message is written there as the raw body, byte for byte, with no dead_letter block. Only then is the original acknowledged (JMS acknowledge() / AMQP accepted).
    • Dead-lettering off → acknowledged and logged (dropped). Artemis’s own max-delivery-attempts is not involved.
    • A failed DLQ write leaves it unsettled (JMS session.recover(), AMQP released) and is reported; the redelivery is rejected again and the write is retried.

    Only an explicitly configured on_unknown_urn strategy is read (fail when none is set); an onUnknownUrn hook, or a strategy it implies, never applies, and dead_letter without 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>.dlq therefore must not assume every record is a canonical envelope. The Laravel babelqueue-artemis driver 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_version property. 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’s meta.schema_version check after decoding, and a body that fails that check is poison. Locked by the version_gate_artemis conformance section.

  • attempts is reconciled to max(body.attempts, delivery-count). The AMQP delivery-count is 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-based JMSXDeliveryCount, subtracting 1 to arrive at the same 0-based attempts. 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_backoff says. 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_attempts or maxTries). Defaults differ too: 5 in the PHP Kafka router, 0 in the PHP Dispatcher, 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.