babelqueue 1.21.1__tar.gz → 1.23.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/workflows/ci.yml +18 -3
- {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/workflows/release.yml +17 -2
- {babelqueue-1.21.1 → babelqueue-1.23.0}/CHANGELOG.md +168 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/PKG-INFO +110 -7
- {babelqueue-1.21.1 → babelqueue-1.23.0}/README.md +109 -6
- babelqueue-1.23.0/conformance-known-gaps.json +1 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/pyproject.toml +1 -1
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/__init__.py +3 -1
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/_envelope_shape.py +3 -2
- babelqueue-1.23.0/src/babelqueue/_poison_record.py +92 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/app.py +314 -30
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/artemis_transport.py +2 -1
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/codec.py +46 -1
- babelqueue-1.23.0/src/babelqueue/kafka_retry.py +336 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/kafka_transport.py +193 -56
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/pika_transport.py +3 -1
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/pulsar_transport.py +35 -2
- babelqueue-1.23.0/src/babelqueue/redis_transport.py +671 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/sqs_transport.py +41 -3
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/transport.py +33 -2
- babelqueue-1.23.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- babelqueue-1.23.0/tests/conformance/manifest.json +6619 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/schema/results.example.json +1 -1
- babelqueue-1.23.0/tests/redis_backend.py +76 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_app.py +86 -4
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_behaviour_conformance.py +623 -133
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_conformance.py +14 -4
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_integration.py +29 -6
- babelqueue-1.23.0/tests/test_kafka_retry.py +445 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_transport.py +191 -57
- babelqueue-1.23.0/tests/test_poison_routing.py +511 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_pulsar_transport.py +4 -0
- babelqueue-1.23.0/tests/test_redis_transport.py +1239 -0
- babelqueue-1.21.1/conformance-known-gaps.json +0 -84
- babelqueue-1.21.1/src/babelqueue/redis_transport.py +0 -355
- babelqueue-1.21.1/tests/conformance/CONFORMANCE_VERSION +0 -1
- babelqueue-1.21.1/tests/conformance/manifest.json +0 -1863
- babelqueue-1.21.1/tests/poison-trigger-set.json +0 -392
- babelqueue-1.21.1/tests/test_redis_transport.py +0 -504
- {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/.gitignore +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/LICENSE +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/scripts/check_conformance_results.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/conformance/schema/results.schema.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_celery.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_codec.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_django.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_headers.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_kafka_integration_logic.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_otel.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_reencode_regression.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_replay.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_schema.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.21.1 → babelqueue-1.23.0}/tests/test_sqs_transport.py +0 -0
|
@@ -12,7 +12,8 @@ permissions:
|
|
|
12
12
|
jobs:
|
|
13
13
|
test:
|
|
14
14
|
name: Python ${{ matrix.python }}
|
|
15
|
-
|
|
15
|
+
# setup-python ships no 3.9 build for ubuntu-26.04 (ubuntu-latest); keep 3.9 on 24.04 while it stays supported.
|
|
16
|
+
runs-on: ${{ matrix.python == '3.9' && 'ubuntu-24.04' || 'ubuntu-latest' }}
|
|
16
17
|
strategy:
|
|
17
18
|
fail-fast: false
|
|
18
19
|
matrix:
|
|
@@ -104,6 +105,8 @@ jobs:
|
|
|
104
105
|
|
|
105
106
|
- name: Run full suite with coverage gate (>=90%)
|
|
106
107
|
env:
|
|
108
|
+
# A real redis:7 is mandatory here: no silent skip or fakeredis fallback (shared runner contract).
|
|
109
|
+
BQ_REQUIRE_REDIS: "1"
|
|
107
110
|
BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
|
|
108
111
|
BABELQUEUE_TEST_AMQP: amqp://guest:guest@localhost:5672/
|
|
109
112
|
SQS_ENDPOINT: http://localhost:9324
|
|
@@ -162,9 +165,19 @@ jobs:
|
|
|
162
165
|
conformance:
|
|
163
166
|
name: Conformance suite in sync + results.json
|
|
164
167
|
runs-on: ubuntu-latest
|
|
168
|
+
services:
|
|
169
|
+
redis:
|
|
170
|
+
image: redis:7
|
|
171
|
+
ports:
|
|
172
|
+
- 6379:6379
|
|
173
|
+
options: >-
|
|
174
|
+
--health-cmd "redis-cli ping"
|
|
175
|
+
--health-interval 5s
|
|
176
|
+
--health-timeout 3s
|
|
177
|
+
--health-retries 10
|
|
165
178
|
env:
|
|
166
179
|
# The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
|
|
167
|
-
BQ_CONFORMANCE_VERSION: 1.
|
|
180
|
+
BQ_CONFORMANCE_VERSION: 1.7.0
|
|
168
181
|
# sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
|
|
169
182
|
# than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
|
|
170
183
|
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
|
|
@@ -190,11 +203,13 @@ jobs:
|
|
|
190
203
|
- name: Install
|
|
191
204
|
run: |
|
|
192
205
|
python -m pip install --upgrade pip
|
|
193
|
-
pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk jsonschema
|
|
206
|
+
pip install -e ".[dev,celery,django,otel,redis]" opentelemetry-sdk jsonschema
|
|
194
207
|
|
|
195
208
|
- name: Run the conformance suite and write results.json
|
|
196
209
|
env:
|
|
197
210
|
BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
|
|
211
|
+
BQ_REQUIRE_REDIS: "1"
|
|
212
|
+
BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
|
|
198
213
|
run: pytest -q tests/test_conformance.py
|
|
199
214
|
|
|
200
215
|
- name: Validate results.json (schema, bindings, sections)
|
|
@@ -14,9 +14,21 @@ jobs:
|
|
|
14
14
|
# Recommended for PyPI Trusted Publishing — configure a "pypi" environment +
|
|
15
15
|
# a trusted publisher on PyPI (repo BabelQueue/babelqueue-python, this workflow).
|
|
16
16
|
environment: pypi
|
|
17
|
+
# redis_layout (conformance 1.7.0) drives the real Lua scripts: a real redis:7 is mandatory,
|
|
18
|
+
# exactly as in ci.yml's `conformance` job — without it the cases are reported as failures.
|
|
19
|
+
services:
|
|
20
|
+
redis:
|
|
21
|
+
image: redis:7
|
|
22
|
+
ports:
|
|
23
|
+
- 6379:6379
|
|
24
|
+
options: >-
|
|
25
|
+
--health-cmd "redis-cli ping"
|
|
26
|
+
--health-interval 5s
|
|
27
|
+
--health-timeout 3s
|
|
28
|
+
--health-retries 10
|
|
17
29
|
env:
|
|
18
30
|
# The pinned conformance release (ADR-0032). Keep identical to ci.yml.
|
|
19
|
-
BQ_CONFORMANCE_VERSION: 1.
|
|
31
|
+
BQ_CONFORMANCE_VERSION: 1.7.0
|
|
20
32
|
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
|
|
21
33
|
permissions:
|
|
22
34
|
id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
|
|
@@ -40,9 +52,12 @@ jobs:
|
|
|
40
52
|
- name: Install & test (writes results.json)
|
|
41
53
|
env:
|
|
42
54
|
BQ_CONFORMANCE_RESULTS: ${{ runner.temp }}/results.json
|
|
55
|
+
# A real redis:7 is mandatory: no silent skip or fakeredis fallback (shared runner contract).
|
|
56
|
+
BQ_REQUIRE_REDIS: "1"
|
|
57
|
+
BABELQUEUE_TEST_REDIS: redis://localhost:6379/0
|
|
43
58
|
run: |
|
|
44
59
|
python -m pip install --upgrade pip build
|
|
45
|
-
pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk jsonschema
|
|
60
|
+
pip install -e ".[dev,celery,django,otel,redis]" opentelemetry-sdk jsonschema
|
|
46
61
|
pytest
|
|
47
62
|
test -s "$BQ_CONFORMANCE_RESULTS" # the conformance run must have produced results.json
|
|
48
63
|
|
|
@@ -9,6 +9,174 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
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
|
+
|
|
82
|
+
## [1.22.0] - 2026-10-24
|
|
83
|
+
|
|
84
|
+
Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at
|
|
85
|
+
`schema_version: 1`; the vendored conformance suite moves to v1.6.0 and
|
|
86
|
+
`conformance-known-gaps.json` is now empty.
|
|
87
|
+
|
|
88
|
+
### Changed
|
|
89
|
+
- The Redis poison dead-letter element is now the ADR-0035 record (raw splice, or the `raw`/`raw_encoding` wrapper) carrying a `dead_letter` block, instead of the bare element (broker-bindings section 1). A legacy `__bq_frame` header is unwrapped, not re-applied. Dropped the dead `_reject_json_constant` helper in the Kafka transport.
|
|
90
|
+
- **Redis: a stored value that is not valid UTF-8 no longer crashes the reserve.** The client built from a URL now uses `encoding_errors="surrogateescape"`, replies are decoded losslessly, and the settle / release / dead-letter calls send the exact stored bytes back, so such a poison body becomes the ADR-0035 base64 wrapper (no U+FFFD) and its reservation is actually removed. A client you pass in with `decode_responses=True` should set `encoding_errors="surrogateescape"` as well (redis-py's default strict decoding raises on such a reply).
|
|
91
|
+
- **Poison DLQ record shape follows ADR-0035** (Kafka): a strict JSON object body is spliced byte for byte with a `dead_letter` member, anything else is wrapped as `{"raw": ..., "raw_encoding"?: "base64", "dead_letter": ...}`; a body that is not valid UTF-8 is stored losslessly as base64 instead of with U+FFFD, and the conformance `poison_dlq` section runs.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
- **Invalid UTF-8 is poison in both Redis layouts (broker-bindings section 1.1).** A stored value (or zset `:reserved` member) that is not valid UTF-8 is dead-lettered on first delivery as the ADR-0035 `raw`/`raw_encoding: "base64"` wrapper, even when the codec or `cjson` could decode it: it is never handed to a handler and never retried, released or re-published, and the exact stored bytes are used for the settle script, so no byte is rewritten.
|
|
95
|
+
- **Redis, Laravel-compatible layout: a poison reservation is dead-lettered in every
|
|
96
|
+
configuration.** The DLQ write no longer depends on `dead_letter_enabled` (default `False`) or
|
|
97
|
+
`on_unknown_urn`: an undecodable body or one without a numeric `attempts` is written raw to
|
|
98
|
+
`<queue>.dlq` and acked instead of being logged and dropped (broker-bindings section 1.1,
|
|
99
|
+
ADR-0033; same as the Go reference). Other version-gated routes keep their previous behaviour.
|
|
100
|
+
- **Redis, Laravel-compatible layout: a job with a non-numeric `attempts` or an undecodable body no
|
|
101
|
+
longer aborts the reserve script.** It is reserved raw (`pcall` guard), dead-lettered byte for
|
|
102
|
+
byte and acked, leaving nothing in `:reserved` (ADR-0033 (c)).
|
|
103
|
+
- **Redis, legacy layout: a `kill -9` no longer strands a message in `<queue>:processing`.** Every
|
|
104
|
+
reservation writes a `<token>:<raw>` member into `<queue>:processing:deadlines`; a sweep before
|
|
105
|
+
each reserve moves lapsed reservations back to the end of `<queue>`. Ack, release and dead-letter
|
|
106
|
+
are gated on their own reservation, so a late ack never touches a newer one. The legacy mode
|
|
107
|
+
remains the default and the `<queue>:processing` key name is unchanged.
|
|
108
|
+
- **Kafka without retry tiers: a failing handler no longer stops the worker** (Go
|
|
109
|
+
`leaveRepublishUnsettled` parity). The transport still raises `BabelQueueError` (section 6.5), but
|
|
110
|
+
the runtime now catches a failed retry release, logs it (`babelqueue.app`, ERROR, "NOT
|
|
111
|
+
acknowledged"), leaves the record unsettled (no commit; a rewind-capable transport seeks back and
|
|
112
|
+
the loop pauses `_rewind_pause` first) and keeps consuming. Previously the error escaped
|
|
113
|
+
`consume()`. Applies to any transport whose retry release fails.
|
|
114
|
+
- **Kafka poison follows Go `routePoison` when no DLQ is configured.** With the DLQ on
|
|
115
|
+
(`dead_letter=True` or `on_unknown_urn=DEAD_LETTER`) the wrapped record is written to
|
|
116
|
+
`<topic>.dlq` (`dead_letter.reason: "poison"`) and then committed. With the DLQ off the poison
|
|
117
|
+
record is **not** written anywhere: `on_unknown_urn=DELETE` drops it, every other strategy takes
|
|
118
|
+
the bounded FAIL path (retried through the retry tiers up to `max_attempts` with a bare
|
|
119
|
+
`{"attempts": N}` body, then dropped; without tiers the record is redelivered in place).
|
|
120
|
+
Operational note: when the DLQ is on, `<topic>.dlq` must exist; a failed write leaves the record
|
|
121
|
+
uncommitted and rewound (it is never dropped).
|
|
122
|
+
- **Unknown-URN `RELEASE` and the retry release leave the delivery unsettled on failure** (Go
|
|
123
|
+
`leaveRepublishUnsettled`): a failed re-publish of an unknown-URN message is no longer counted as
|
|
124
|
+
a handler failure and can no longer lead to the record being acked and lost.
|
|
125
|
+
- **Redis, legacy `:processing` layout: an undecodable body is dead-lettered on its first
|
|
126
|
+
delivery.** A body Go's typed `Decode` rejects (neither a JSON object nor `null`, or wrong member types such as
|
|
127
|
+
a string `attempts`/`meta`; same criterion as Kafka poison) is written verbatim to `<queue>.dlq`
|
|
128
|
+
through the gated settle script and removed from `:processing`, in every configuration, instead of
|
|
129
|
+
being retried until `max_attempts`. A valid JSON object without an identity keeps the existing
|
|
130
|
+
bounded path.
|
|
131
|
+
- **Kafka: a record without a valid `job` / `urn` is poison** (ADR-0034 section 10), not an
|
|
132
|
+
unknown URN: with the DLQ on it is dead-lettered wrapped with `dead_letter.reason: "poison"` and
|
|
133
|
+
never retried; with the DLQ off it follows Go `routePoison` (see above).
|
|
134
|
+
|
|
135
|
+
- **Redis, Laravel-compatible layout: the reserve script no longer aborts on a body that decodes
|
|
136
|
+
but cannot be re-encoded** (`cjson.encode` of `inf`/`NaN`, e.g. `1e400`, raised after the `LPOP`
|
|
137
|
+
and lost the job). The encode is now inside `pcall`; the job is reserved raw and takes the poison
|
|
138
|
+
path. Note: fakeredis' `cjson` serialises `inf` as `Infinity` and masks this; it is proven by a
|
|
139
|
+
real-Redis test (CI `redis:7`) and by a Lua test against a failing `cjson` stub.
|
|
140
|
+
- **`on_unknown_urn="delete"` no longer discards a Redis poison reservation.** It is dead-lettered
|
|
141
|
+
byte for byte like the Kafka poison record already was; a genuine unknown URN is still deleted.
|
|
142
|
+
- **Redis, Laravel-compatible layout: `dead_letter` is atomic and gated.** One script `ZREM`s the
|
|
143
|
+
reservation and writes the dead-letter record and its notify entry; an expired reservation writes
|
|
144
|
+
nothing (and logs a warning) instead of publishing a duplicate.
|
|
145
|
+
- **A message dropped because it is undeliverable and no dead-letter queue is configured is
|
|
146
|
+
logged** (`babelqueue.app`, WARNING) instead of disappearing silently.
|
|
147
|
+
- **Kafka `RetryReinjector` no longer loses records:** it kept one held record per partition, so a
|
|
148
|
+
second record on the same partition overwrote the first. It now keeps all of them in order.
|
|
149
|
+
- **A worker on a tier-less Kafka transport with `max_attempts` > 1 warns at start-up** that a
|
|
150
|
+
failing handler cannot be retried: the record is left uncommitted and redelivered.
|
|
151
|
+
- Internal: the legacy `:processing` settle script takes `[token, raw]` and builds the deadline
|
|
152
|
+
member itself.
|
|
153
|
+
|
|
154
|
+
### Added
|
|
155
|
+
- `RedisTransport(retry_after=90, migration_batch_size=-1)` (also `?retry_after=` in the URL;
|
|
156
|
+
`None` / `null` disables recovery). The Laravel-compatible `retry_after` default is now 90 s
|
|
157
|
+
(was 60 s).
|
|
158
|
+
- SQS: `SqsTransport.publish(..., delay_seconds=)` (`DelaySeconds` 0..900; out of range or FIFO
|
|
159
|
+
raises `BabelQueueError`).
|
|
160
|
+
- Kafka: `KafkaTransport(retry_tiers_ms=...)` with `DEFAULT_RETRY_TIERS_MS`, `retry_topic()` and
|
|
161
|
+
the `babelqueue.kafka_retry.RetryReinjector` consumer that moves parked records from
|
|
162
|
+
`<topic>.retry.<n>` back to the work topic (broker-bindings section 6.4). Opt-in: without tiers a
|
|
163
|
+
retry / `publish_delayed` raises `BabelQueueError` (the transport raises; the worker logs it and
|
|
164
|
+
leaves the record uncommitted, see Fixed).
|
|
165
|
+
- Worker-side retry backoff: `BabelQueue(retry_backoff=, retry_backoff_multiplier=,
|
|
166
|
+
retry_backoff_max=, retry_jitter=)` (ADR-0034 section 9); the defaults keep an immediate release.
|
|
167
|
+
- Conformance runner covers the v1.6.0 sections (`redis_layout`, `redis_delay`, `retry_backoff`,
|
|
168
|
+
`kafka_poison`, ...). CI runs the Redis cases against a real `redis:7` with `BQ_REQUIRE_REDIS=1`.
|
|
169
|
+
|
|
170
|
+
### Changed
|
|
171
|
+
- **Kafka retries go to the retry tiers.** With `retry_tiers_ms` set a failed message is parked on
|
|
172
|
+
`<queue>.retry.<n>` and needs a running `RetryReinjector`; the retry topics must exist. Without
|
|
173
|
+
tiers a retry now raises from the transport instead of republishing onto the work topic; the
|
|
174
|
+
runtime logs it and redelivers the record rather than stopping the worker.
|
|
175
|
+
|
|
176
|
+
### Documentation
|
|
177
|
+
- README: Redis layouts, the limit that the Laravel-compatible layout carries no out-of-band headers (ADR-0033 §5),
|
|
178
|
+
Kafka retry tiers, `LogAppendTime` on the tier topics, retry backoff, SQS `delay_seconds`.
|
|
179
|
+
|
|
12
180
|
## [1.21.1] - 2026-10-20
|
|
13
181
|
|
|
14
182
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.23.0
|
|
4
4
|
Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
|
|
5
5
|
Project-URL: Homepage, https://babelqueue.com
|
|
6
6
|
Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
|
|
@@ -195,7 +195,7 @@ app.run() # consume forever (Ctrl-C to stop)
|
|
|
195
195
|
messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
|
|
196
196
|
- **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
|
|
197
197
|
Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
|
|
198
|
-
see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
|
|
198
|
+
see below), `kafka://` (opt-in retry tiers, see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
|
|
199
199
|
and `memory://` (in-process, great for tests/local). Bring your own by passing
|
|
200
200
|
`transport=...`.
|
|
201
201
|
|
|
@@ -248,7 +248,10 @@ transport **always** releases with `ChangeMessageVisibility` (broker-bindings
|
|
|
248
248
|
attempt counter, so a shutdown release also consumes an attempt. The default
|
|
249
249
|
delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
|
|
250
250
|
message is visible again immediately; delays outside 0..43200 s are clamped with
|
|
251
|
-
a warning.
|
|
251
|
+
a warning. A **per-message delay** at publish time is
|
|
252
|
+
`SqsTransport.publish(queue, body, delay_seconds=n)` (`DelaySeconds`, 0..900 on a
|
|
253
|
+
standard queue; a larger value, or any per-message delay on a FIFO queue, raises
|
|
254
|
+
`BabelQueueError` instead of being truncated or dropped).
|
|
252
255
|
|
|
253
256
|
**Poison-loop risk:** a message that always fails is redelivered at once until
|
|
254
257
|
`max_attempts` is reached. If the receive count is unavailable the SDK cannot
|
|
@@ -257,11 +260,98 @@ advance attempts at all. **Configure a native `RedrivePolicy`**
|
|
|
257
260
|
queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
|
|
258
261
|
handler's dependencies need time to recover.
|
|
259
262
|
|
|
263
|
+
### Retry back-off
|
|
264
|
+
|
|
265
|
+
`BabelQueue(..., retry_backoff=, retry_backoff_multiplier=, retry_backoff_max=, retry_jitter=)`
|
|
266
|
+
set the worker-side retry delay (ADR-0034 section 9): after the n-th failure the message is
|
|
267
|
+
released with `d(n) = min(retry_backoff_max, retry_backoff * retry_backoff_multiplier ** (n - 1))`
|
|
268
|
+
seconds (`retry_backoff_max=0` means no cap) and `retry_jitter="full"` draws `uniform(0, d(n))`.
|
|
269
|
+
The defaults (`retry_backoff=0`, multiplier 1, jitter `"none"`) keep the previous behaviour: an
|
|
270
|
+
immediate release. How the delay is honoured depends on the transport: SQS uses it as the
|
|
271
|
+
visibility timeout, the Laravel-compatible Redis layout as the `:delayed` score and Kafka picks a
|
|
272
|
+
retry tier (see below).
|
|
273
|
+
|
|
274
|
+
### Kafka retry tiers and the re-inject consumer
|
|
275
|
+
|
|
276
|
+
Kafka has no per-message delay, so delayed delivery and retry back-off are SDK-owned **retry
|
|
277
|
+
topics** (broker-bindings section 6.4, ADR-0034). They are opt-in: pass `retry_tiers_ms` to the
|
|
278
|
+
transport (the recommended set is `DEFAULT_RETRY_TIERS_MS` = 5 s, 30 s, 5 min, 30 min):
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
from babelqueue import BabelQueue
|
|
282
|
+
from babelqueue.kafka_transport import KafkaTransport, DEFAULT_RETRY_TIERS_MS
|
|
283
|
+
from babelqueue.kafka_retry import RetryReinjector
|
|
284
|
+
|
|
285
|
+
transport = KafkaTransport("kafka://localhost:9092", retry_tiers_ms=DEFAULT_RETRY_TIERS_MS)
|
|
286
|
+
app = BabelQueue(transport=transport, queue="orders", max_attempts=5, dead_letter=True)
|
|
287
|
+
|
|
288
|
+
# Run one next to the workers, otherwise a parked message is never delivered again:
|
|
289
|
+
RetryReinjector("kafka://localhost:9092", work_topics=["orders"],
|
|
290
|
+
retry_tiers_ms=DEFAULT_RETRY_TIERS_MS).run()
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
A failed message is republished (attempts + 1) to `<queue>.retry.<n>`, where
|
|
294
|
+
`n = min(attempts, N - 1) + 1`; `publish_delayed` picks the smallest tier that covers the requested
|
|
295
|
+
delay. Create the `<queue>.retry.<n>` topics up front. The `RetryReinjector` copies each record's
|
|
296
|
+
value and key byte for byte back to its work topic once the tier delay has elapsed (headers
|
|
297
|
+
`bq-delay` / `bq-original-topic` are dropped) and commits only after the broker confirmed the
|
|
298
|
+
write, so delivery stays at-least-once. It holds every record it has accepted per partition (oldest
|
|
299
|
+
first, a record never overtakes the one before it) and a failed write rewinds the partition to the
|
|
300
|
+
failed record. Without `retry_tiers_ms`, `publish_delayed` and a retry
|
|
301
|
+
raise `BabelQueueError` and nothing is produced. The worker does not stop on that error: it logs
|
|
302
|
+
it, leaves the record uncommitted (the consumer seeks back to it and the loop pauses briefly, so
|
|
303
|
+
the record is redelivered and never lost) and keeps consuming. A worker started without tiers
|
|
304
|
+
while `max_attempts` is above 1 logs a warning at start-up: a failing handler cannot be retried
|
|
305
|
+
(the record keeps failing in place) until tiers are configured. The re-inject delay is measured from the record
|
|
306
|
+
timestamp: set `message.timestamp.type=LogAppendTime` on the `.retry.<n>` topics so that
|
|
307
|
+
timestamp is the broker's, not a producer clock's.
|
|
308
|
+
|
|
309
|
+
**Records without an identity are poison.** A Kafka record whose JSON object has neither a valid
|
|
310
|
+
string `job` nor a valid string `urn` (ADR-0034 section 10) is poison, not an unknown URN. With
|
|
311
|
+
the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered on its first delivery
|
|
312
|
+
(wrapped with `dead_letter.reason: "poison"`, key preserved) and never retried: the record is
|
|
313
|
+
written to `<topic>.dlq` (which must exist) and its offset is committed once that write
|
|
314
|
+
is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
|
|
315
|
+
`on_unknown_urn=DELETE` drops it, any other strategy retries it through the retry tiers up to
|
|
316
|
+
`max_attempts` and then drops it. **Without retry tiers** that bounded retry has no tier to use, so
|
|
317
|
+
(with the DLQ off and `on_unknown_urn` not `DELETE`) the poison record is committed, dropped and logged
|
|
318
|
+
instead of blocking the partition (section 6.5 degrade-to-delete, ADR-0036 decision 5); enable
|
|
319
|
+
`dead_letter` to keep it in `<topic>.dlq`. A failing handler without tiers still cannot be retried, as above.
|
|
320
|
+
|
|
321
|
+
### Poison messages on every other transport
|
|
322
|
+
|
|
323
|
+
A message body that does not decode into an envelope (not a JSON object, malformed, a wrongly typed
|
|
324
|
+
member, or bytes that are not valid UTF-8, even inside a JSON string) or that has neither a valid
|
|
325
|
+
string `job` nor a valid string `urn` is **poison** on every transport (ADR-0036 decisions 2, 4 and
|
|
326
|
+
6; conformance `identity_verdict`). It is never handed to a handler and never takes the
|
|
327
|
+
unknown-URN strategies, which are for a valid envelope whose URN has no handler:
|
|
328
|
+
|
|
329
|
+
- DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`): written to `<queue>.dlq` at once, with no
|
|
330
|
+
retry, as the ADR-0035 record (`dead_letter.reason: "poison"`, the original body preserved,
|
|
331
|
+
base64 plus `raw_encoding` when it is not UTF-8), then acknowledged. A failed write leaves it
|
|
332
|
+
unacknowledged.
|
|
333
|
+
- `on_unknown_urn=DELETE`: acknowledged and dropped (logged).
|
|
334
|
+
- DLQ off: the bounded `FAIL` path (`RELEASE` is replaced by it): retried up to `max_attempts`, then
|
|
335
|
+
dropped and logged. The count comes from the broker where it has one (SQS receive count, Pulsar
|
|
336
|
+
`bq-attempts` / redelivery count), otherwise from a bare `{"attempts": n}` body the retry leaves.
|
|
337
|
+
|
|
338
|
+
On Redis (both layouts) an identity-less body is dead-lettered as poison in **every** configuration,
|
|
339
|
+
including `on_unknown_urn=DELETE` and dead-lettering off (broker-bindings section 1.1). On the legacy
|
|
340
|
+
(`<queue>:processing`) layout a body that Go's typed decode rejects is dead-lettered raw on its first
|
|
341
|
+
delivery and removed from `:processing`.
|
|
342
|
+
|
|
260
343
|
### Sharing a Redis queue with Laravel
|
|
261
344
|
|
|
262
345
|
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
|
|
263
|
-
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack).
|
|
264
|
-
|
|
346
|
+
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
|
|
347
|
+
writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
|
|
348
|
+
`<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
|
|
349
|
+
reservations back to the end of the list, so a `kill -9` no longer strands a message in
|
|
350
|
+
`:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
|
|
351
|
+
(default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
|
|
352
|
+
`migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
|
|
353
|
+
migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
|
|
354
|
+
Laravel-compatible mode:
|
|
265
355
|
|
|
266
356
|
```python
|
|
267
357
|
app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
|
|
@@ -278,8 +368,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
|
|
|
278
368
|
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
|
|
279
369
|
one queue without losing or double-processing messages. Before each pop, expired
|
|
280
370
|
reserved/delayed jobs migrate back to the ready list, so a crashed worker's
|
|
281
|
-
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=
|
|
282
|
-
(or the `key_prefix` / `retry_after` constructor args).
|
|
371
|
+
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
|
|
372
|
+
(or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
|
|
373
|
+
does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
|
|
374
|
+
and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
|
|
375
|
+
either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
|
|
376
|
+
it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
|
|
377
|
+
a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
|
|
378
|
+
reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
|
|
379
|
+
record and its notify entry); if the reservation expired first, nothing is written and a warning
|
|
380
|
+
says so, because the job was already migrated back and will be delivered again.
|
|
381
|
+
|
|
382
|
+
**Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
|
|
383
|
+
has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
|
|
384
|
+
carried there. They are dropped without an error; the `trace_id` inside the envelope is still
|
|
385
|
+
preserved. The default (legacy) layout carries them.
|
|
283
386
|
|
|
284
387
|
## Framework adapters — Celery & Django
|
|
285
388
|
|
|
@@ -141,7 +141,7 @@ app.run() # consume forever (Ctrl-C to stop)
|
|
|
141
141
|
messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
|
|
142
142
|
- **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
|
|
143
143
|
Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
|
|
144
|
-
see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
|
|
144
|
+
see below), `kafka://` (opt-in retry tiers, see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
|
|
145
145
|
and `memory://` (in-process, great for tests/local). Bring your own by passing
|
|
146
146
|
`transport=...`.
|
|
147
147
|
|
|
@@ -194,7 +194,10 @@ transport **always** releases with `ChangeMessageVisibility` (broker-bindings
|
|
|
194
194
|
attempt counter, so a shutdown release also consumes an attempt. The default
|
|
195
195
|
delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
|
|
196
196
|
message is visible again immediately; delays outside 0..43200 s are clamped with
|
|
197
|
-
a warning.
|
|
197
|
+
a warning. A **per-message delay** at publish time is
|
|
198
|
+
`SqsTransport.publish(queue, body, delay_seconds=n)` (`DelaySeconds`, 0..900 on a
|
|
199
|
+
standard queue; a larger value, or any per-message delay on a FIFO queue, raises
|
|
200
|
+
`BabelQueueError` instead of being truncated or dropped).
|
|
198
201
|
|
|
199
202
|
**Poison-loop risk:** a message that always fails is redelivered at once until
|
|
200
203
|
`max_attempts` is reached. If the receive count is unavailable the SDK cannot
|
|
@@ -203,11 +206,98 @@ advance attempts at all. **Configure a native `RedrivePolicy`**
|
|
|
203
206
|
queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
|
|
204
207
|
handler's dependencies need time to recover.
|
|
205
208
|
|
|
209
|
+
### Retry back-off
|
|
210
|
+
|
|
211
|
+
`BabelQueue(..., retry_backoff=, retry_backoff_multiplier=, retry_backoff_max=, retry_jitter=)`
|
|
212
|
+
set the worker-side retry delay (ADR-0034 section 9): after the n-th failure the message is
|
|
213
|
+
released with `d(n) = min(retry_backoff_max, retry_backoff * retry_backoff_multiplier ** (n - 1))`
|
|
214
|
+
seconds (`retry_backoff_max=0` means no cap) and `retry_jitter="full"` draws `uniform(0, d(n))`.
|
|
215
|
+
The defaults (`retry_backoff=0`, multiplier 1, jitter `"none"`) keep the previous behaviour: an
|
|
216
|
+
immediate release. How the delay is honoured depends on the transport: SQS uses it as the
|
|
217
|
+
visibility timeout, the Laravel-compatible Redis layout as the `:delayed` score and Kafka picks a
|
|
218
|
+
retry tier (see below).
|
|
219
|
+
|
|
220
|
+
### Kafka retry tiers and the re-inject consumer
|
|
221
|
+
|
|
222
|
+
Kafka has no per-message delay, so delayed delivery and retry back-off are SDK-owned **retry
|
|
223
|
+
topics** (broker-bindings section 6.4, ADR-0034). They are opt-in: pass `retry_tiers_ms` to the
|
|
224
|
+
transport (the recommended set is `DEFAULT_RETRY_TIERS_MS` = 5 s, 30 s, 5 min, 30 min):
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from babelqueue import BabelQueue
|
|
228
|
+
from babelqueue.kafka_transport import KafkaTransport, DEFAULT_RETRY_TIERS_MS
|
|
229
|
+
from babelqueue.kafka_retry import RetryReinjector
|
|
230
|
+
|
|
231
|
+
transport = KafkaTransport("kafka://localhost:9092", retry_tiers_ms=DEFAULT_RETRY_TIERS_MS)
|
|
232
|
+
app = BabelQueue(transport=transport, queue="orders", max_attempts=5, dead_letter=True)
|
|
233
|
+
|
|
234
|
+
# Run one next to the workers, otherwise a parked message is never delivered again:
|
|
235
|
+
RetryReinjector("kafka://localhost:9092", work_topics=["orders"],
|
|
236
|
+
retry_tiers_ms=DEFAULT_RETRY_TIERS_MS).run()
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A failed message is republished (attempts + 1) to `<queue>.retry.<n>`, where
|
|
240
|
+
`n = min(attempts, N - 1) + 1`; `publish_delayed` picks the smallest tier that covers the requested
|
|
241
|
+
delay. Create the `<queue>.retry.<n>` topics up front. The `RetryReinjector` copies each record's
|
|
242
|
+
value and key byte for byte back to its work topic once the tier delay has elapsed (headers
|
|
243
|
+
`bq-delay` / `bq-original-topic` are dropped) and commits only after the broker confirmed the
|
|
244
|
+
write, so delivery stays at-least-once. It holds every record it has accepted per partition (oldest
|
|
245
|
+
first, a record never overtakes the one before it) and a failed write rewinds the partition to the
|
|
246
|
+
failed record. Without `retry_tiers_ms`, `publish_delayed` and a retry
|
|
247
|
+
raise `BabelQueueError` and nothing is produced. The worker does not stop on that error: it logs
|
|
248
|
+
it, leaves the record uncommitted (the consumer seeks back to it and the loop pauses briefly, so
|
|
249
|
+
the record is redelivered and never lost) and keeps consuming. A worker started without tiers
|
|
250
|
+
while `max_attempts` is above 1 logs a warning at start-up: a failing handler cannot be retried
|
|
251
|
+
(the record keeps failing in place) until tiers are configured. The re-inject delay is measured from the record
|
|
252
|
+
timestamp: set `message.timestamp.type=LogAppendTime` on the `.retry.<n>` topics so that
|
|
253
|
+
timestamp is the broker's, not a producer clock's.
|
|
254
|
+
|
|
255
|
+
**Records without an identity are poison.** A Kafka record whose JSON object has neither a valid
|
|
256
|
+
string `job` nor a valid string `urn` (ADR-0034 section 10) is poison, not an unknown URN. With
|
|
257
|
+
the DLQ on (`dead_letter` or `on_unknown_urn=DEAD_LETTER`) it is dead-lettered on its first delivery
|
|
258
|
+
(wrapped with `dead_letter.reason: "poison"`, key preserved) and never retried: the record is
|
|
259
|
+
written to `<topic>.dlq` (which must exist) and its offset is committed once that write
|
|
260
|
+
is confirmed; a failed write leaves it uncommitted and rewound. With the DLQ off nothing is written:
|
|
261
|
+
`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 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
|
|
287
|
+
delivery and removed from `:processing`.
|
|
288
|
+
|
|
206
289
|
### Sharing a Redis queue with Laravel
|
|
207
290
|
|
|
208
291
|
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
|
|
209
|
-
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack).
|
|
210
|
-
|
|
292
|
+
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
|
|
293
|
+
writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
|
|
294
|
+
`<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
|
|
295
|
+
reservations back to the end of the list, so a `kill -9` no longer strands a message in
|
|
296
|
+
`:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
|
|
297
|
+
(default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
|
|
298
|
+
`migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
|
|
299
|
+
migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
|
|
300
|
+
Laravel-compatible mode:
|
|
211
301
|
|
|
212
302
|
```python
|
|
213
303
|
app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
|
|
@@ -224,8 +314,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
|
|
|
224
314
|
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
|
|
225
315
|
one queue without losing or double-processing messages. Before each pop, expired
|
|
226
316
|
reserved/delayed jobs migrate back to the ready list, so a crashed worker's
|
|
227
|
-
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=
|
|
228
|
-
(or the `key_prefix` / `retry_after` constructor args).
|
|
317
|
+
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
|
|
318
|
+
(or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
|
|
319
|
+
does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
|
|
320
|
+
and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
|
|
321
|
+
either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
|
|
322
|
+
it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
|
|
323
|
+
a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
|
|
324
|
+
reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
|
|
325
|
+
record and its notify entry); if the reservation expired first, nothing is written and a warning
|
|
326
|
+
says so, because the job was already migrated back and will be delivered again.
|
|
327
|
+
|
|
328
|
+
**Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
|
|
329
|
+
has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
|
|
330
|
+
carried there. They are dropped without an error; the `trace_id` inside the envelope is still
|
|
331
|
+
preserved. The default (legacy) layout carries them.
|
|
229
332
|
|
|
230
333
|
## Framework adapters — Celery & Django
|
|
231
334
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"gaps": []}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.23.0"
|
|
8
8
|
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -31,6 +31,7 @@ from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
|
31
31
|
from .routing import UnknownUrnStrategy
|
|
32
32
|
from .transport import (
|
|
33
33
|
BatchPopper,
|
|
34
|
+
DeadLetterWriter,
|
|
34
35
|
HeaderPublisher,
|
|
35
36
|
InMemoryTransport,
|
|
36
37
|
ReceivedMessage,
|
|
@@ -41,7 +42,7 @@ from .transport import (
|
|
|
41
42
|
Transport,
|
|
42
43
|
)
|
|
43
44
|
|
|
44
|
-
__version__ = "1.
|
|
45
|
+
__version__ = "1.23.0"
|
|
45
46
|
|
|
46
47
|
__all__ = [
|
|
47
48
|
"BabelQueue",
|
|
@@ -55,6 +56,7 @@ __all__ = [
|
|
|
55
56
|
"InMemoryTransport",
|
|
56
57
|
"ReceivedMessage",
|
|
57
58
|
"BatchPopper",
|
|
59
|
+
"DeadLetterWriter",
|
|
58
60
|
"HeaderPublisher",
|
|
59
61
|
"Redeliverer",
|
|
60
62
|
"Rewinder",
|
|
@@ -5,7 +5,7 @@ Go reference's ``babelqueue.Decode``, except that member names are case-sensitiv
|
|
|
5
5
|
|
|
6
6
|
* the body must be exactly one JSON value (no BOM, trailing tokens, ``NaN`` / ``Infinity``, malformed
|
|
7
7
|
numbers); a bare ``null`` decodes to an empty envelope, any other non-object root is rejected;
|
|
8
|
-
* ``
|
|
8
|
+
* ``trace_id``, ``meta.id|queue|lang`` and ``dead_letter.reason|error|exception|original_queue|lang``
|
|
9
9
|
are string-or-null;
|
|
10
10
|
* ``attempts``, ``meta.schema_version|created_at`` and ``dead_letter.failed_at|attempts`` are
|
|
11
11
|
integer-or-null: an integer literal (no fraction or exponent) that fits ``int64``;
|
|
@@ -14,6 +14,8 @@ Go reference's ``babelqueue.Decode``, except that member names are case-sensitiv
|
|
|
14
14
|
* member names are matched exactly as spelled (case-sensitive, as the contract schema and the core
|
|
15
15
|
decoder read them) and every occurrence of a duplicated key is checked; any other member (a
|
|
16
16
|
differently-cased name or a non-string ``urn`` alias included) is only checked for syntax.
|
|
17
|
+
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, and ``identity_verdict``, v1.7.0).
|
|
17
19
|
|
|
18
20
|
A well-typed but non-conformant envelope is not poison: Go's consume path never calls ``Accepts``.
|
|
19
21
|
"""
|
|
@@ -64,7 +66,6 @@ _DEAD_LETTER = {
|
|
|
64
66
|
"attempts": _INTEGER,
|
|
65
67
|
}
|
|
66
68
|
_TOP = {
|
|
67
|
-
"job": _STRING,
|
|
68
69
|
"trace_id": _STRING,
|
|
69
70
|
"attempts": _INTEGER,
|
|
70
71
|
"data": _DATA,
|