Skip to content

Commit 372e674

Browse files
Release v1.23.0: poison routing on every transport (ADR-0036) (#12)
* Route identity-less and undecodable bodies as poison on every transport (ADR-0036) Identity-less JSON objects and invalid UTF-8 inside JSON strings are now poison instead of unknown-URN on AMQP, Pulsar, Artemis and SQS; Redis dead-letters identity-less bodies unconditionally; Kafka without retry tiers degrades to commit + drop per section 6.5. Vendors conformance v1.7.0 and runs its identity_verdict and pulsar_exhaustion sections. * Release v1.23.0
1 parent 3340687 commit 372e674

26 files changed

Lines changed: 2044 additions & 62 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ jobs:
177177
--health-retries 10
178178
env:
179179
# The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
180-
BQ_CONFORMANCE_VERSION: 1.6.0
180+
BQ_CONFORMANCE_VERSION: 1.7.0
181181
# sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
182182
# than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
183183
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
# Recommended for PyPI Trusted Publishing — configure a "pypi" environment +
1515
# a trusted publisher on PyPI (repo BabelQueue/babelqueue-python, this workflow).
1616
environment: pypi
17-
# redis_layout (conformance 1.6.0) drives the real Lua scripts: a real redis:7 is mandatory,
17+
# redis_layout (conformance 1.7.0) drives the real Lua scripts: a real redis:7 is mandatory,
1818
# exactly as in ci.yml's `conformance` job — without it the cases are reported as failures.
1919
services:
2020
redis:
@@ -28,7 +28,7 @@ jobs:
2828
--health-retries 10
2929
env:
3030
# The pinned conformance release (ADR-0032). Keep identical to ci.yml.
31-
BQ_CONFORMANCE_VERSION: 1.6.0
31+
BQ_CONFORMANCE_VERSION: 1.7.0
3232
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
3333
permissions:
3434
id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed

‎CHANGELOG.md‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,76 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
## [1.23.0] - 2026-10-25
13+
14+
Implements ADR-0036 (post-R1 parity) decisions 2, 4, 5 and 6. A MINOR: every change is a
15+
consumer-side settle decision, so the envelope stays at `schema_version: 1`. The vendored
16+
conformance suite moves to v1.7.0 (new sections `identity_verdict` and `pulsar_exhaustion`, both
17+
passing; `conformance-known-gaps.json` stays empty).
18+
19+
### Changed (behaviour)
20+
- **A body that does not decode, or has no identity, is poison on every transport (ADR-0036
21+
decisions 4 and 6).** A JSON object that has neither a valid string `job` nor a valid string
22+
`urn` (ADR-0032 section 6) used to be an *unknown URN*, and on SQS, AMQP, Pulsar, Azure Service
23+
Bus, Artemis and the memory transport a body that did not decode was decoded leniently into an
24+
empty envelope and retried as an unknown URN, finally dead-lettered as `reason: "unknown_urn"`
25+
without the original body. Both are now **poison** and take the poison route of Go's
26+
`routePoison`: they are never handed to a handler and never take the unknown-URN strategies
27+
(those remain for a valid envelope whose URN has no handler). With the DLQ on (`dead_letter=True`
28+
or `on_unknown_urn=DEAD_LETTER`) the message is written to the DLQ at once, with no retry, as the
29+
ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved: a strict JSON
30+
object spliced byte for byte, anything else as `raw`, base64 plus `raw_encoding` when it is not
31+
valid UTF-8) and acknowledged after the write; `on_unknown_urn=DELETE` drops it (logged); with
32+
the DLQ off `RELEASE` is replaced by the bounded `FAIL` path (re-published with `attempts + 1`
33+
up to `max_attempts`, then dropped and logged). An operator who relied on a poison body being
34+
retried through the unknown-URN strategy, or on a `reason: "unknown_urn"` record for it, sees the
35+
new behaviour. The classification is `EnvelopeCodec.poison_cause(raw)` (new, pure): the typed
36+
decode of the Go reference plus the identity rule; `decode` / `decode_lenient` are unchanged.
37+
- **Pulsar poison records carry `reason: "poison"`** (they were `unknown_urn`). The Pulsar
38+
transport also reports the reconciled `bq-attempts` / redelivery count to the runtime
39+
(`ReceivedMessage.native_attempts`, new), as does SQS (`ApproximateReceiveCount - 1`), so a poison
40+
body, which cannot carry `attempts`, is still bounded by `max_attempts`.
41+
- **Redis: an identity-less body is dead-lettered unconditionally (ADR-0036 decision 4,
42+
broker-bindings section 1.1 "Identity-less body").** In both layouts (legacy `:processing`
43+
and Laravel-compatible zset) a body that decodes but names no identity (including a bare `null`,
44+
which used to follow the bounded `FAIL` path) is handed over as transport poison: it is written
45+
to `<queue>.dlq` as the ADR-0035 record with `reason: "poison"` in every configuration
46+
(`on_unknown_urn=DELETE` and dead-lettering off change nothing), never handed to a handler and
47+
never retried. Nothing else in the Redis transport changes.
48+
- **Kafka without retry tiers: a poison record no longer blocks the partition (ADR-0036 decision
49+
5, broker-bindings section 6.5 degrade-to-delete).** With no `retry_tiers_ms`, dead-lettering off
50+
and `on_unknown_urn` not `DELETE`, the bounded retry of a poison record (including an
51+
identity-less body) needs a tier and raised, leaving the record uncommitted with `bq-attempts`
52+
never advancing. It is now committed, dropped and logged, and the partition advances. A failing
53+
handler and `RELEASE` of a valid envelope keep the section 6.5 rule (they still raise
54+
`BabelQueueError` without tiers). The start-up warning for a tierless transport now says so; enable
55+
`dead_letter` to keep such records in `<topic>.dlq`.
56+
- A message dropped after its retries are exhausted with no DLQ configured is now logged
57+
(`babelqueue.app`, WARNING) instead of being dropped silently.
58+
59+
- **Pulsar: a failed DLQ write or retry publish is negatively acknowledged (Go `Rewind`).**
60+
`PulsarTransport` is now a `Rewinder` (`negative_acknowledge`), so the unsettled original is
61+
redelivered by the broker instead of staying un-acknowledged for the life of the consumer. A
62+
graceful-shutdown release of an in-flight Pulsar message is now a nack as well rather than a
63+
re-publish (on a `Shared` subscription the native redelivery count advances; on `Exclusive` /
64+
`Failover` it does not, which is harmless: `bq-attempts` carries the attempt limit).
65+
66+
### Fixed
67+
- **Bytes that are not valid UTF-8 no longer crash the AMQP, Pulsar and Artemis consumers.** They
68+
were decoded strictly in `pop`; they are now kept losslessly (`surrogateescape`). Invalid UTF-8
69+
is poison wherever it sits, **also inside a JSON string** (`EnvelopeCodec.poison_cause`,
70+
error-handling section 7): it is never handed to a handler and is dead-lettered as the ADR-0035
71+
base64 record, like on Redis and Kafka.
72+
73+
### Added
74+
- `EnvelopeCodec.poison_cause(raw, *, require_identity=True)`; `Transport.classifies_poison`
75+
(Kafka and Redis classify poison themselves and opt out of the runtime's classification);
76+
`ReceivedMessage.native_attempts`.
77+
- Conformance runners for `identity_verdict` (the core verdict on all 97 bodies, plus the fate on
78+
the memory transport) and `pulsar_exhaustion` (all 29 rows, driven through the real
79+
`PulsarTransport` and runtime over a duck-typed client), and per-transport poison tests.
80+
- `ci.yml` / `release.yml` pin conformance `1.7.0` (the `fetch.sh` SHA-256 is unchanged).
81+
1282
## [1.22.0] - 2026-10-24
1383

1484
Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at

‎README.md‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,31 @@ the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered o
259259
written to `<topic>.dlq` (which must exist) and its offset is committed once that write
260260
is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
261261
`on_unknown_urn=DELETE` drops it, any other strategy retries it through the retry tiers up to
262-
`max_attempts` and then drops it (without tiers the record is redelivered in place, as above). On
263-
the Redis legacy (`<queue>:processing`) layout a body that Go's typed decode rejects (neither a JSON
264-
object nor `null`, or wrong member types) is dead-lettered raw on its first
262+
`max_attempts` and then drops it. **Without retry tiers** that bounded retry has no tier to use, so
263+
(with the DLQ off and `on_unknown_urn` not `DELETE`) the poison record is committed, dropped and logged
264+
instead of blocking the partition (section 6.5 degrade-to-delete, ADR-0036 decision 5); enable
265+
`dead_letter` to keep it in `<topic>.dlq`. A failing handler without tiers still cannot be retried, as above.
266+
267+
### Poison messages on every other transport
268+
269+
A message body that does not decode into an envelope (not a JSON object, malformed, a wrongly typed
270+
member, or bytes that are not valid UTF-8, even inside a JSON string) or that has neither a valid
271+
string `job` nor a valid string `urn` is **poison** on every transport (ADR-0036 decisions 2, 4 and
272+
6; conformance `identity_verdict`). It is never handed to a handler and never takes the
273+
unknown-URN strategies, which are for a valid envelope whose URN has no handler:
274+
275+
- DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`): written to `<queue>.dlq` at once, with no
276+
retry, as the ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved,
277+
base64 plus `raw_encoding` when it is not UTF-8), then acknowledged. A failed write leaves it
278+
unacknowledged.
279+
- `on_unknown_urn=DELETE`: acknowledged and dropped (logged).
280+
- DLQ off: the bounded `FAIL` path (`RELEASE` is replaced by it): retried up to `max_attempts`, then
281+
dropped and logged. The count comes from the broker where it has one (SQS receive count, Pulsar
282+
`bq-attempts` / redelivery count), otherwise from a bare `{"attempts": n}` body the retry leaves.
283+
284+
On Redis (both layouts) an identity-less body is dead-lettered as poison in **every** configuration,
285+
including `on_unknown_urn=DELETE` and dead-lettering off (broker-bindings section 1.1). On the legacy
286+
(`<queue>:processing`) layout a body that Go's typed decode rejects is dead-lettered raw on its first
265287
delivery and removed from `:processing`.
266288

267289
### Sharing a Redis queue with Laravel

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "babelqueue"
7-
version = "1.22.0"
7+
version = "1.23.0"
88
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
99
readme = "README.md"
1010
requires-python = ">=3.9"

‎src/babelqueue/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
Transport,
4343
)
4444

45-
__version__ = "1.22.0"
45+
__version__ = "1.23.0"
4646

4747
__all__ = [
4848
"BabelQueue",

‎src/babelqueue/_envelope_shape.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
decoder read them) and every occurrence of a duplicated key is checked; any other member (a
1616
differently-cased name or a non-string ``urn`` alias included) is only checked for syntax.
1717
A non-string ``job`` is **not** poison either: the identity resolver ignores it (ADR-0032 section 6),
18-
so the body is readable and simply carries no identity (conformance ``kafka_poison``, v1.6.0).
18+
so the body is readable and simply carries no identity (conformance ``kafka_poison``, v1.6.0, and ``identity_verdict``, v1.7.0).
1919
2020
A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
2121
"""

0 commit comments

Comments
 (0)