Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ jobs:
--health-retries 10
env:
# The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
BQ_CONFORMANCE_VERSION: 1.7.0
BQ_CONFORMANCE_VERSION: 1.8.0
# sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
# than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
# Recommended for PyPI Trusted Publishing — configure a "pypi" environment +
# a trusted publisher on PyPI (repo BabelQueue/babelqueue-python, this workflow).
environment: pypi
# redis_layout (conformance 1.7.0) drives the real Lua scripts: a real redis:7 is mandatory,
# redis_layout (conformance 1.7.0+) drives the real Lua scripts: a real redis:7 is mandatory,
# exactly as in ci.yml's `conformance` job — without it the cases are reported as failures.
services:
redis:
Expand All @@ -28,7 +28,7 @@ jobs:
--health-retries 10
env:
# The pinned conformance release (ADR-0032). Keep identical to ci.yml.
BQ_CONFORMANCE_VERSION: 1.7.0
BQ_CONFORMANCE_VERSION: 1.8.0
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
permissions:
id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
Expand Down
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,71 @@ The envelope wire format is versioned separately by `meta.schema_version`

## [Unreleased]

## [1.24.0] - 2026-10-26

Implements conformance **1.8.0** (vendored from the `v1.8.0` release asset, verified with
`fetch.sh --check`; `BQ_CONFORMANCE_VERSION` is now `1.8.0` in `ci.yml` and `release.yml`). The
envelope stays at `schema_version: 1`; nothing here touches the wire (ADR-0037). The new
`version_gate_pulsar`, `version_gate_artemis`, `version_gate_sqs` and `version_gate_asb` sections run
and pass (17 cases each; Python has the `on_unknown_urn` option, so no row is skipped). A MINOR.

### Changed (behaviour)
- **With the DLQ off, SQS and Azure Service Bus no longer drop a version-gated message or retry a
poison body; they are left to the broker's native mechanism (ADR-0037 decision 1 rule 3 and
decision 2 "Go and Python"; broker-bindings 3.5, 4.5, 5.7; owner decision 2026-10-25).**
- *Version-gated message* (an unsupported `bq-schema-version`), `dead_letter` off and a strategy
other than `DELETE` or `DEAD_LETTER`: it used to be acknowledged and dropped. It is now **not**
acknowledged. SQS: no `DeleteMessage`; visibility expiry redelivers it (the gate rejects it
again) and the queue's `RedrivePolicy` moves it to the native DLQ after `maxReceiveCount`. Azure
Service Bus: an explicit native `DeadLetter` (reason `unsupported schema version`) into
`$DeadLetterQueue` instead of `Complete`.
- *Poison body* (invalid JSON or an identity-less object), `dead_letter` off and a strategy other
than `DELETE` or `DEAD_LETTER`: it used to take the bounded `FAIL` path (re-publish an empty
envelope up to `max_attempts`, then drop). On SQS and Azure Service Bus it is now settled
natively **on the first delivery** exactly as above (Azure Service Bus reason `poison`): no
re-publish, no retry, no ack, body unchanged, the event is logged. Every other transport
(Pulsar, Artemis, AMQP, Kafka, Redis, memory) keeps its behaviour; `DELETE` (ack and drop),
`dead_letter=True` and the `DEAD_LETTER` strategy (the raw record, then ack) are unchanged on
SQS and Azure Service Bus too.
- A deployment that relied on SQS/Azure Service Bus silently dropping these messages now needs a
`RedrivePolicy` (SQS) or accepts them in `$DeadLetterQueue` (Azure Service Bus), or sets
`dead_letter=True` / `on_unknown_urn=DELETE`.

### Fixed
- **SQS: the default dead-letter queue name is now `<queue>-dlq` (FIFO `<queue>-dlq.fifo`) (ADR-0037
decision 7).** The old default `<queue>.dlq` was never a valid Amazon SQS queue name (a name may hold only
alphanumerics, hyphens and underscores, plus one `.fifo` suffix), so a DLQ write by default could not find a
queue on real SQS. `dead_letter_suffix` now defaults to `None` ("not set"); unless `dead_letter_queue` is set
the runtime asks the transport's new optional `DeadLetterNamer` capability
(`dead_letter_queue_name(queue, suffix)`, exported from `babelqueue`), which `SqsTransport` implements. The
runtime also hands it an explicit `dead_letter_suffix`; on a FIFO queue the `.fifo` suffix always stays last,
an explicit suffix included: `orders.fifo` with `dead_letter_suffix="-x"` is `orders-x.fifo`, never
`orders.fifo-x` (an SQS FIFO name must end in `.fifo`; before, that explicit suffix produced the uncreatable
`orders.fifo-x`). An explicit queue name behaves as before; every other transport keeps `<queue>.dlq` (the
attribute `App.dead_letter_suffix` is `None` instead of `".dlq"` until set). Deployments
that relied on the SQS default must name the DLQ `<queue>-dlq`, or pass `dead_letter_suffix=".dlq"` /
`dead_letter_queue=` to keep the old name. An explicitly empty `dead_letter_suffix=""` now counts as "no suffix" on SQS (`-dlq`); before, it made the DLQ the source queue itself.

### Added
- **`babelqueue.transport.NativeSettler`**, an optional `Transport` capability
(`settle_native(message, reason, description)`). With the DLQ off the runtime calls it instead of
acknowledging a version-gated message (and, on the poison route, instead of the bounded retry) on
a transport that offers it; the decision follows the capability, never the transport's name.
`SqsTransport.settle_native` is a deliberate no-op on the wire; `AsbTransport.settle_native`
dead-letters natively (falling back to `abandon_message` for a receiver without
`dead_letter_message`). Transports without it are unchanged.
- **`babelqueue.transport.Abandoner` and `AsbTransport.abandon`.** When the dead-letter write of a
*version-gated* message fails, Azure Service Bus now `Abandon`s the peek-lock (broker-bindings 4.5,
"A failed write `Abandon`s it") so the broker redelivers it at once, instead of leaving it locked until
the lock lapses (up to 5 minutes). Only that branch abandons (owner decision 2026-10-25): `AsbTransport`
is deliberately **not** a `Rewinder`, so a graceful-shutdown release and a failed retry re-publish keep
their previous handling (a copy is re-published and the original completed), and a failed dead-letter
write of a poison body is still left to its lock. Tests pin all three. The `version_gate_asb` runner
asserts the `Abandon` on its `unsettled` rows.
- Conformance `version_gate_*` runners (`version_gate_pulsar` / `version_gate_artemis` through the
runtime, `version_gate_sqs` / `version_gate_asb` through the real transports) and their entries in
the results writer.

## [1.23.0] - 2026-10-25

Implements ADR-0036 (post-R1 parity) decisions 2, 4, 5 and 6. A MINOR: every change is a
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,13 @@ untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message
anything else is not decoded: it reaches the runtime on the reserved route
`babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
with no `dead_letter` block) when `dead_letter` is enabled (or `on_unknown_urn=DEAD_LETTER`), and
`on_unknown_urn=DELETE` drops it, whatever `max_attempts` is. With `dead_letter` off, **SQS and Azure
Service Bus no longer drop it** (ADR-0037): it is left to the broker's native mechanism and logged
(SQS: no `DeleteMessage`, so visibility expiry redelivers it — the gate rejects it again — and the
queue's `RedrivePolicy` moves it to the native DLQ after `maxReceiveCount`; Azure Service Bus: an
explicit native `DeadLetter` with reason `unsupported schema version` into `$DeadLetterQueue`
instead of `Complete`). Kafka, Artemis and Pulsar still acknowledge and drop it. If the dead-letter write fails the message is
left unacknowledged (an error is logged), is never retried and never reaches a handler; the
dead-letter write is attempted again when the broker brings it back, which depends on the
broker:
Expand Down Expand Up @@ -199,10 +204,17 @@ a warning. A **per-message delay** at publish time is
standard queue; a larger value, or any per-message delay on a FIFO queue, raises
`BabelQueueError` instead of being truncated or dropped).

**Poison bodies.** A body that does not decode, or has no identity, is never retried by the SDK
(ADR-0037): `dead_letter=True` writes the ADR-0035 record to the DLQ (`<queue>-dlq` on SQS, `<queue>.dlq` elsewhere) and then deletes it,
`on_unknown_urn=DELETE` deletes it, and with the DLQ off it is **not deleted and not re-sent**: the
transport's `settle_native` capability leaves it to SQS on the first delivery (visibility expiry,
then the queue's `RedrivePolicy`; the event is logged). Azure Service Bus does the same with a native
`DeadLetter` (reason `poison`). Every other transport keeps the bounded retry up to `max_attempts`.

**Poison-loop risk:** a message that always fails is redelivered at once until
`max_attempts` is reached. If the receive count is unavailable the SDK cannot
advance attempts at all. **Configure a native `RedrivePolicy`**
(`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
(`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>-dlq`, the SQS default DLQ name: FIFO `<queue>-dlq.fifo`, because an SQS queue name cannot contain a dot; `dead_letter_queue` overrides it, and `dead_letter_suffix` replaces `-dlq`, placed before `.fifo` on a FIFO queue) on every SQS
queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
handler's dependencies need time to recover.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "babelqueue"
version = "1.23.0"
version = "1.24.0"
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
8 changes: 7 additions & 1 deletion src/babelqueue/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
from .routing import UnknownUrnStrategy
from .transport import (
Abandoner,
BatchPopper,
DeadLetterNamer,
DeadLetterWriter,
HeaderPublisher,
InMemoryTransport,
NativeSettler,
ReceivedMessage,
Redeliverer,
Rewinder,
Expand All @@ -42,7 +45,7 @@
Transport,
)

__version__ = "1.23.0"
__version__ = "1.24.0"

__all__ = [
"BabelQueue",
Expand All @@ -55,9 +58,12 @@
"Transport",
"InMemoryTransport",
"ReceivedMessage",
"Abandoner",
"BatchPopper",
"DeadLetterNamer",
"DeadLetterWriter",
"HeaderPublisher",
"NativeSettler",
"Redeliverer",
"Rewinder",
"RoutedPublisher",
Expand Down
Loading
Loading