babelqueue 1.21.0__tar.gz → 1.22.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.0 → babelqueue-1.22.0}/.github/workflows/ci.yml +18 -3
- {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/workflows/release.yml +17 -2
- {babelqueue-1.21.0 → babelqueue-1.22.0}/CHANGELOG.md +107 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/PKG-INFO +88 -7
- {babelqueue-1.21.0 → babelqueue-1.22.0}/README.md +87 -6
- babelqueue-1.22.0/conformance-known-gaps.json +1 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/pyproject.toml +1 -1
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/__init__.py +3 -1
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/_envelope_shape.py +3 -2
- babelqueue-1.22.0/src/babelqueue/_poison_record.py +92 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/app.py +204 -19
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/asb_transport.py +34 -1
- babelqueue-1.22.0/src/babelqueue/kafka_retry.py +336 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/kafka_transport.py +187 -56
- babelqueue-1.22.0/src/babelqueue/redis_transport.py +661 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/sqs_transport.py +30 -3
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/transport.py +19 -2
- babelqueue-1.22.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- babelqueue-1.22.0/tests/conformance/manifest.json +5802 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/schema/results.example.json +1 -1
- babelqueue-1.22.0/tests/redis_backend.py +76 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_app.py +63 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_asb_transport.py +13 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_behaviour_conformance.py +450 -133
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_conformance.py +12 -4
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_integration.py +29 -6
- babelqueue-1.22.0/tests/test_kafka_retry.py +389 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_transport.py +191 -57
- babelqueue-1.22.0/tests/test_redis_transport.py +1152 -0
- babelqueue-1.21.0/conformance-known-gaps.json +0 -84
- babelqueue-1.21.0/src/babelqueue/redis_transport.py +0 -355
- babelqueue-1.21.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- babelqueue-1.21.0/tests/conformance/manifest.json +0 -1863
- babelqueue-1.21.0/tests/poison-trigger-set.json +0 -392
- babelqueue-1.21.0/tests/test_redis_transport.py +0 -504
- {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/.gitignore +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/LICENSE +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/scripts/check_conformance_results.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/conformance/schema/results.schema.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_celery.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_codec.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_django.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_headers.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_kafka_integration_logic.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_otel.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_reencode_regression.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_replay.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_schema.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.21.0 → babelqueue-1.22.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.6.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.6.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.6.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,113 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.22.0] - 2026-10-24
|
|
13
|
+
|
|
14
|
+
Implements ADR-0033 (Redis) and ADR-0034 (Kafka tiers, backoff, poison). The envelope stays at
|
|
15
|
+
`schema_version: 1`; the vendored conformance suite moves to v1.6.0 and
|
|
16
|
+
`conformance-known-gaps.json` is now empty.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- 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.
|
|
20
|
+
- **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).
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **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.
|
|
25
|
+
- **Redis, Laravel-compatible layout: a poison reservation is dead-lettered in every
|
|
26
|
+
configuration.** The DLQ write no longer depends on `dead_letter_enabled` (default `False`) or
|
|
27
|
+
`on_unknown_urn`: an undecodable body or one without a numeric `attempts` is written raw to
|
|
28
|
+
`<queue>.dlq` and acked instead of being logged and dropped (broker-bindings section 1.1,
|
|
29
|
+
ADR-0033; same as the Go reference). Other version-gated routes keep their previous behaviour.
|
|
30
|
+
- **Redis, Laravel-compatible layout: a job with a non-numeric `attempts` or an undecodable body no
|
|
31
|
+
longer aborts the reserve script.** It is reserved raw (`pcall` guard), dead-lettered byte for
|
|
32
|
+
byte and acked, leaving nothing in `:reserved` (ADR-0033 (c)).
|
|
33
|
+
- **Redis, legacy layout: a `kill -9` no longer strands a message in `<queue>:processing`.** Every
|
|
34
|
+
reservation writes a `<token>:<raw>` member into `<queue>:processing:deadlines`; a sweep before
|
|
35
|
+
each reserve moves lapsed reservations back to the end of `<queue>`. Ack, release and dead-letter
|
|
36
|
+
are gated on their own reservation, so a late ack never touches a newer one. The legacy mode
|
|
37
|
+
remains the default and the `<queue>:processing` key name is unchanged.
|
|
38
|
+
- **Kafka without retry tiers: a failing handler no longer stops the worker** (Go
|
|
39
|
+
`leaveRepublishUnsettled` parity). The transport still raises `BabelQueueError` (section 6.5), but
|
|
40
|
+
the runtime now catches a failed retry release, logs it (`babelqueue.app`, ERROR, "NOT
|
|
41
|
+
acknowledged"), leaves the record unsettled (no commit; a rewind-capable transport seeks back and
|
|
42
|
+
the loop pauses `_rewind_pause` first) and keeps consuming. Previously the error escaped
|
|
43
|
+
`consume()`. Applies to any transport whose retry release fails.
|
|
44
|
+
- **Kafka poison follows Go `routePoison` when no DLQ is configured.** With the DLQ on
|
|
45
|
+
(`dead_letter=True` or `on_unknown_urn=DEAD_LETTER`) the wrapped record is written to
|
|
46
|
+
`<topic>.dlq` (`dead_letter.reason: "poison"`) and then committed. With the DLQ off the poison
|
|
47
|
+
record is **not** written anywhere: `on_unknown_urn=DELETE` drops it, every other strategy takes
|
|
48
|
+
the bounded FAIL path (retried through the retry tiers up to `max_attempts` with a bare
|
|
49
|
+
`{"attempts": N}` body, then dropped; without tiers the record is redelivered in place).
|
|
50
|
+
Operational note: when the DLQ is on, `<topic>.dlq` must exist; a failed write leaves the record
|
|
51
|
+
uncommitted and rewound (it is never dropped).
|
|
52
|
+
- **Unknown-URN `RELEASE` and the retry release leave the delivery unsettled on failure** (Go
|
|
53
|
+
`leaveRepublishUnsettled`): a failed re-publish of an unknown-URN message is no longer counted as
|
|
54
|
+
a handler failure and can no longer lead to the record being acked and lost.
|
|
55
|
+
- **Redis, legacy `:processing` layout: an undecodable body is dead-lettered on its first
|
|
56
|
+
delivery.** A body Go's typed `Decode` rejects (neither a JSON object nor `null`, or wrong member types such as
|
|
57
|
+
a string `attempts`/`meta`; same criterion as Kafka poison) is written verbatim to `<queue>.dlq`
|
|
58
|
+
through the gated settle script and removed from `:processing`, in every configuration, instead of
|
|
59
|
+
being retried until `max_attempts`. A valid JSON object without an identity keeps the existing
|
|
60
|
+
bounded path.
|
|
61
|
+
- **Kafka: a record without a valid `job` / `urn` is poison** (ADR-0034 section 10), not an
|
|
62
|
+
unknown URN: with the DLQ on it is dead-lettered wrapped with `dead_letter.reason: "poison"` and
|
|
63
|
+
never retried; with the DLQ off it follows Go `routePoison` (see above).
|
|
64
|
+
|
|
65
|
+
- **Redis, Laravel-compatible layout: the reserve script no longer aborts on a body that decodes
|
|
66
|
+
but cannot be re-encoded** (`cjson.encode` of `inf`/`NaN`, e.g. `1e400`, raised after the `LPOP`
|
|
67
|
+
and lost the job). The encode is now inside `pcall`; the job is reserved raw and takes the poison
|
|
68
|
+
path. Note: fakeredis' `cjson` serialises `inf` as `Infinity` and masks this; it is proven by a
|
|
69
|
+
real-Redis test (CI `redis:7`) and by a Lua test against a failing `cjson` stub.
|
|
70
|
+
- **`on_unknown_urn="delete"` no longer discards a Redis poison reservation.** It is dead-lettered
|
|
71
|
+
byte for byte like the Kafka poison record already was; a genuine unknown URN is still deleted.
|
|
72
|
+
- **Redis, Laravel-compatible layout: `dead_letter` is atomic and gated.** One script `ZREM`s the
|
|
73
|
+
reservation and writes the dead-letter record and its notify entry; an expired reservation writes
|
|
74
|
+
nothing (and logs a warning) instead of publishing a duplicate.
|
|
75
|
+
- **A message dropped because it is undeliverable and no dead-letter queue is configured is
|
|
76
|
+
logged** (`babelqueue.app`, WARNING) instead of disappearing silently.
|
|
77
|
+
- **Kafka `RetryReinjector` no longer loses records:** it kept one held record per partition, so a
|
|
78
|
+
second record on the same partition overwrote the first. It now keeps all of them in order.
|
|
79
|
+
- **A worker on a tier-less Kafka transport with `max_attempts` > 1 warns at start-up** that a
|
|
80
|
+
failing handler cannot be retried: the record is left uncommitted and redelivered.
|
|
81
|
+
- Internal: the legacy `:processing` settle script takes `[token, raw]` and builds the deadline
|
|
82
|
+
member itself.
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
- `RedisTransport(retry_after=90, migration_batch_size=-1)` (also `?retry_after=` in the URL;
|
|
86
|
+
`None` / `null` disables recovery). The Laravel-compatible `retry_after` default is now 90 s
|
|
87
|
+
(was 60 s).
|
|
88
|
+
- SQS: `SqsTransport.publish(..., delay_seconds=)` (`DelaySeconds` 0..900; out of range or FIFO
|
|
89
|
+
raises `BabelQueueError`).
|
|
90
|
+
- Kafka: `KafkaTransport(retry_tiers_ms=...)` with `DEFAULT_RETRY_TIERS_MS`, `retry_topic()` and
|
|
91
|
+
the `babelqueue.kafka_retry.RetryReinjector` consumer that moves parked records from
|
|
92
|
+
`<topic>.retry.<n>` back to the work topic (broker-bindings section 6.4). Opt-in: without tiers a
|
|
93
|
+
retry / `publish_delayed` raises `BabelQueueError` (the transport raises; the worker logs it and
|
|
94
|
+
leaves the record uncommitted, see Fixed).
|
|
95
|
+
- Worker-side retry backoff: `BabelQueue(retry_backoff=, retry_backoff_multiplier=,
|
|
96
|
+
retry_backoff_max=, retry_jitter=)` (ADR-0034 section 9); the defaults keep an immediate release.
|
|
97
|
+
- Conformance runner covers the v1.6.0 sections (`redis_layout`, `redis_delay`, `retry_backoff`,
|
|
98
|
+
`kafka_poison`, ...). CI runs the Redis cases against a real `redis:7` with `BQ_REQUIRE_REDIS=1`.
|
|
99
|
+
|
|
100
|
+
### Changed
|
|
101
|
+
- **Kafka retries go to the retry tiers.** With `retry_tiers_ms` set a failed message is parked on
|
|
102
|
+
`<queue>.retry.<n>` and needs a running `RetryReinjector`; the retry topics must exist. Without
|
|
103
|
+
tiers a retry now raises from the transport instead of republishing onto the work topic; the
|
|
104
|
+
runtime logs it and redelivers the record rather than stopping the worker.
|
|
105
|
+
|
|
106
|
+
### Documentation
|
|
107
|
+
- README: Redis layouts, the limit that the Laravel-compatible layout carries no out-of-band headers (ADR-0033 §5),
|
|
108
|
+
Kafka retry tiers, `LogAppendTime` on the tier topics, retry backoff, SQS `delay_seconds`.
|
|
109
|
+
|
|
110
|
+
## [1.21.1] - 2026-10-20
|
|
111
|
+
|
|
112
|
+
### Fixed
|
|
113
|
+
- **Azure Service Bus: `publish` no longer fails with `Value supplied for int invalid`.** The pure-Python AMQP
|
|
114
|
+
stack in `azure-servicebus` encodes a bare Python int as AMQP int32, so the millisecond `bq-created-at`
|
|
115
|
+
application property (broker-bindings.md §4.3) overflowed at send time. Out-of-int32 integer properties
|
|
116
|
+
are now sent as AMQP `long`, matching the Go, Java, .NET and Node transports. The native projection and
|
|
117
|
+
the envelope body are unchanged.
|
|
118
|
+
|
|
12
119
|
## [1.21.0] - 2026-10-19
|
|
13
120
|
|
|
14
121
|
Implements ADR-0032 (conformance packaging, results format, ASCII trim and the non-string `job` rule).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.22.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,76 @@ 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 tiers the record is redelivered in place, as above). On
|
|
317
|
+
the Redis legacy (`<queue>:processing`) layout a body that Go's typed decode rejects (neither a JSON
|
|
318
|
+
object nor `null`, or wrong member types) is dead-lettered raw on its first
|
|
319
|
+
delivery and removed from `:processing`.
|
|
320
|
+
|
|
260
321
|
### Sharing a Redis queue with Laravel
|
|
261
322
|
|
|
262
323
|
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
|
-
|
|
324
|
+
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
|
|
325
|
+
writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
|
|
326
|
+
`<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
|
|
327
|
+
reservations back to the end of the list, so a `kill -9` no longer strands a message in
|
|
328
|
+
`:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
|
|
329
|
+
(default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
|
|
330
|
+
`migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
|
|
331
|
+
migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
|
|
332
|
+
Laravel-compatible mode:
|
|
265
333
|
|
|
266
334
|
```python
|
|
267
335
|
app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
|
|
@@ -278,8 +346,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
|
|
|
278
346
|
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
|
|
279
347
|
one queue without losing or double-processing messages. Before each pop, expired
|
|
280
348
|
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).
|
|
349
|
+
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
|
|
350
|
+
(or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
|
|
351
|
+
does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
|
|
352
|
+
and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
|
|
353
|
+
either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
|
|
354
|
+
it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
|
|
355
|
+
a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
|
|
356
|
+
reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
|
|
357
|
+
record and its notify entry); if the reservation expired first, nothing is written and a warning
|
|
358
|
+
says so, because the job was already migrated back and will be delivered again.
|
|
359
|
+
|
|
360
|
+
**Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
|
|
361
|
+
has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
|
|
362
|
+
carried there. They are dropped without an error; the `trace_id` inside the envelope is still
|
|
363
|
+
preserved. The default (legacy) layout carries them.
|
|
283
364
|
|
|
284
365
|
## Framework adapters — Celery & Django
|
|
285
366
|
|
|
@@ -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,76 @@ 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 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
|
|
265
|
+
delivery and removed from `:processing`.
|
|
266
|
+
|
|
206
267
|
### Sharing a Redis queue with Laravel
|
|
207
268
|
|
|
208
269
|
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
|
-
|
|
270
|
+
`BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). Each reservation also
|
|
271
|
+
writes a `<token>:<raw>` member (32 lowercase hex characters of token) into the
|
|
272
|
+
`<queue>:processing:deadlines` sorted set, and before every reserve a sweep moves the lapsed
|
|
273
|
+
reservations back to the end of the list, so a `kill -9` no longer strands a message in
|
|
274
|
+
`:processing`; a late ack of a swept reservation touches nothing of the newer one. `retry_after`
|
|
275
|
+
(default **90** seconds; `None` / `?retry_after=null` disables recovery) sets that deadline, and
|
|
276
|
+
`migration_batch_size` (`-1` = all, otherwise `>= 1`) bounds how many due members one sweep or
|
|
277
|
+
migration moves. To consume a **shared** Laravel BabelQueue Redis queue instead, enable
|
|
278
|
+
Laravel-compatible mode:
|
|
211
279
|
|
|
212
280
|
```python
|
|
213
281
|
app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
|
|
@@ -224,8 +292,21 @@ member a Python worker writes is identical to a Laravel worker's — either side
|
|
|
224
292
|
ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
|
|
225
293
|
one queue without losing or double-processing messages. Before each pop, expired
|
|
226
294
|
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).
|
|
295
|
+
in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=90`
|
|
296
|
+
(or the `key_prefix` / `retry_after` / `migration_batch_size` constructor args). A job whose body
|
|
297
|
+
does not decode, or whose `attempts` is not a number, is reserved raw, dead-lettered byte for byte
|
|
298
|
+
and acked, so it never lingers in `:reserved`. That holds when the body cannot be re-encoded
|
|
299
|
+
either (for example a number such as `1e400`, which real Redis' `cjson` refuses to serialise), and
|
|
300
|
+
it holds under `on_unknown_urn="delete"` too: such a message is dead-lettered, never deleted. Without
|
|
301
|
+
a dead-letter queue it is dropped, and the drop is logged as a warning. Dead-lettering a
|
|
302
|
+
reserved message is one atomic script (`ZREM` the reservation, then `RPUSH` the dead-letter
|
|
303
|
+
record and its notify entry); if the reservation expired first, nothing is written and a warning
|
|
304
|
+
says so, because the job was already migrated back and will be delivered again.
|
|
305
|
+
|
|
306
|
+
**Limit (A3):** the Laravel-compatible layout stores the bare envelope (Laravel's reserve script
|
|
307
|
+
has to decode it), so out-of-band transport headers such as the W3C `traceparent` cannot be
|
|
308
|
+
carried there. They are dropped without an error; the `trace_id` inside the envelope is still
|
|
309
|
+
preserved. The default (legacy) layout carries them.
|
|
229
310
|
|
|
230
311
|
## Framework adapters — Celery & Django
|
|
231
312
|
|
|
@@ -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.22.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.22.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).
|
|
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,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""The DLQ record of a poison body (ADR-0035, error-handling.md section 6).
|
|
2
|
+
|
|
3
|
+
A body that is a strict RFC 8259 JSON object (valid UTF-8, no ``NaN`` / ``Infinity`` literal, no
|
|
4
|
+
unpaired surrogate escape, no top-level ``dead_letter`` member) is *spliced* at byte level: every
|
|
5
|
+
original character is kept and only a ``dead_letter`` member is appended, so no value is re-encoded
|
|
6
|
+
(``1e400`` and a 64-bit integer stay as written, a repeated member name is not collapsed). Anything
|
|
7
|
+
else is wrapped as ``{"raw": <string>, "raw_encoding"?: "base64", "dead_letter": ...}``: ``raw`` is
|
|
8
|
+
the body as plain text when it is valid UTF-8, otherwise the standard base64 of the exact bytes with
|
|
9
|
+
``"raw_encoding": "base64"``. U+FFFD substitution never happens.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
_JSON_WHITESPACE = " \t\r\n"
|
|
20
|
+
_STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL)
|
|
21
|
+
_ESCAPE = re.compile(r"\\(u[0-9a-fA-F]{4}|.)", re.DOTALL)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _reject_constant(name: str) -> Any:
|
|
25
|
+
raise ValueError("invalid JSON constant " + name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _has_unpaired_surrogate_escape(text: str) -> bool:
|
|
29
|
+
"""True when a string literal of the (already syntax-valid) JSON ``text`` holds a ``\\uD800``-style
|
|
30
|
+
escape that is not half of a high+low pair."""
|
|
31
|
+
for literal in _STRING_LITERAL.finditer(text):
|
|
32
|
+
pending_high = False
|
|
33
|
+
for escape in _ESCAPE.finditer(literal.group(0)):
|
|
34
|
+
token = escape.group(1)
|
|
35
|
+
code = int(token[1:], 16) if len(token) == 5 and token[0] == "u" else -1
|
|
36
|
+
is_high = 0xD800 <= code <= 0xDBFF
|
|
37
|
+
is_low = 0xDC00 <= code <= 0xDFFF
|
|
38
|
+
if pending_high and not is_low:
|
|
39
|
+
return True
|
|
40
|
+
if is_low and not pending_high:
|
|
41
|
+
return True
|
|
42
|
+
pending_high = is_high
|
|
43
|
+
if pending_high:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_spliceable_object(text: str) -> bool:
|
|
49
|
+
"""Whether ``text`` is one strict JSON object that can be spliced (ADR-0035 section 1)."""
|
|
50
|
+
last_pairs: List[Optional[List[Tuple[str, Any]]]] = [None]
|
|
51
|
+
|
|
52
|
+
def hook(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]:
|
|
53
|
+
last_pairs[0] = pairs # the outermost object is the last one the decoder completes
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
parsed = json.loads(text, parse_constant=_reject_constant, object_pairs_hook=hook)
|
|
58
|
+
except (ValueError, RecursionError):
|
|
59
|
+
return False
|
|
60
|
+
if not isinstance(parsed, dict) or last_pairs[0] is None:
|
|
61
|
+
return False
|
|
62
|
+
if any(key == "dead_letter" for key, _ in last_pairs[0]):
|
|
63
|
+
return False
|
|
64
|
+
return not _has_unpaired_surrogate_escape(text)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_poison_record(text: str, dead_letter_block: Dict[str, Any]) -> str:
|
|
68
|
+
"""The DLQ record body for the poison body ``text``.
|
|
69
|
+
|
|
70
|
+
``text`` is the body as the transport carries it: bytes that were not UTF-8 appear as
|
|
71
|
+
``surrogateescape`` code points. ``dead_letter_block`` is the ``dead_letter`` member's value."""
|
|
72
|
+
try:
|
|
73
|
+
data = text.encode("utf-8", "surrogateescape")
|
|
74
|
+
except UnicodeEncodeError:
|
|
75
|
+
data = text.encode("utf-8", "surrogatepass")
|
|
76
|
+
try:
|
|
77
|
+
decoded: Optional[str] = data.decode("utf-8")
|
|
78
|
+
except UnicodeDecodeError:
|
|
79
|
+
decoded = None
|
|
80
|
+
|
|
81
|
+
block = json.dumps(dead_letter_block, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
82
|
+
if decoded is not None and _is_spliceable_object(decoded):
|
|
83
|
+
trimmed = decoded.strip(_JSON_WHITESPACE)
|
|
84
|
+
if trimmed[1:-1].strip(_JSON_WHITESPACE) == "":
|
|
85
|
+
return '{"dead_letter":' + block + "}"
|
|
86
|
+
return trimmed[:-1] + ',"dead_letter":' + block + "}"
|
|
87
|
+
|
|
88
|
+
if decoded is not None:
|
|
89
|
+
raw = json.dumps(decoded, ensure_ascii=False)
|
|
90
|
+
return '{"raw":' + raw + ',"dead_letter":' + block + "}"
|
|
91
|
+
encoded = base64.b64encode(data).decode("ascii")
|
|
92
|
+
return '{"raw":"' + encoded + '","raw_encoding":"base64","dead_letter":' + block + "}"
|