babelqueue 1.24.0__tar.gz → 1.25.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.24.0 → babelqueue-1.25.0}/.github/workflows/ci.yml +1 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/workflows/release.yml +1 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/CHANGELOG.md +87 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/PKG-INFO +1 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/pyproject.toml +1 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/__init__.py +7 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/app.py +145 -24
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/artemis_transport.py +26 -4
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/asb_transport.py +42 -5
- babelqueue-1.25.0/src/babelqueue/dead_letter.py +87 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/headers.py +71 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/kafka_transport.py +68 -2
- babelqueue-1.25.0/src/babelqueue/otel.py +677 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/pulsar_transport.py +31 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/redrive.py +11 -3
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/transport.py +24 -0
- babelqueue-1.25.0/tests/conformance/CONFORMANCE_VERSION +1 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/manifest.json +2335 -2
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/schema/results.example.json +25 -1
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_artemis_transport.py +68 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_asb_transport.py +135 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_behaviour_conformance.py +2 -2
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_conformance.py +3 -0
- babelqueue-1.25.0/tests/test_dead_letter_observer.py +298 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_headers.py +18 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_retry.py +58 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_transport.py +140 -0
- babelqueue-1.25.0/tests/test_otel.py +553 -0
- babelqueue-1.25.0/tests/test_otel_conformance.py +480 -0
- babelqueue-1.25.0/tests/test_publish_envelope.py +181 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_pulsar_transport.py +101 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_redrive.py +8 -0
- babelqueue-1.24.0/src/babelqueue/dead_letter.py +0 -39
- babelqueue-1.24.0/src/babelqueue/otel.py +0 -265
- babelqueue-1.24.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- babelqueue-1.24.0/tests/test_otel.py +0 -263
- {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/.gitignore +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/LICENSE +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/README.md +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/conformance-known-gaps.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/scripts/check_conformance_results.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/_envelope_shape.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/_poison_record.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/kafka_retry.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/blank-ascii-job-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/dead-letter-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/duplicate-keys-last-wins.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-job-and-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-job-nonascii-space-and-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/invalid-wrong-case-job.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-array-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-bool-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-empty-string-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-missing-job-array-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-null-no-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-null-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-number-blank-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-number-nonstring-urn.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-number-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/job-type-object-urn-fallback.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/meta-ext-vendor-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-cr.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-ff.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-lf.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-space.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-tab.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-job-vt.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-cr.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-ff.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-lf.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-space.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-tab.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-blank-trace-id-vt.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-job-and-urn-both-blank.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-reemit-job-ascii-padded.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-reemit-job-unicode-padded.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-reemit-trace-id-padded.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-unicode-job-emsp.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-unicode-job-ideographic.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-unicode-job-nbsp.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-urn-only-ascii-blank.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/trim-urn-only-unicode-space.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/urn-only-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/fixtures/wrong-case-keys-roundtrip.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/conformance/schema/results.schema.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fakes/confluent_kafka/__init__.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/redis_backend.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_app.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_celery.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_codec.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_django.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_integration.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_kafka_integration_logic.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_poison_routing.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_reencode_regression.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_replay.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_schema.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.24.0 → babelqueue-1.25.0}/tests/test_sqs_transport.py +0 -0
|
@@ -177,7 +177,7 @@ jobs:
|
|
|
177
177
|
--health-retries 10
|
|
178
178
|
env:
|
|
179
179
|
# The pinned conformance release (ADR-0032). Bump together with tests/conformance/.
|
|
180
|
-
BQ_CONFORMANCE_VERSION: 1.
|
|
180
|
+
BQ_CONFORMANCE_VERSION: 1.9.0
|
|
181
181
|
# sha256 of that release's fetch.sh: the script runs on the runner, so it is pinned rather
|
|
182
182
|
# than trusted from a mutable tag. Bump together with BQ_CONFORMANCE_VERSION.
|
|
183
183
|
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
|
|
@@ -28,7 +28,7 @@ jobs:
|
|
|
28
28
|
--health-retries 10
|
|
29
29
|
env:
|
|
30
30
|
# The pinned conformance release (ADR-0032). Keep identical to ci.yml.
|
|
31
|
-
BQ_CONFORMANCE_VERSION: 1.
|
|
31
|
+
BQ_CONFORMANCE_VERSION: 1.9.0
|
|
32
32
|
BQ_FETCH_SH_SHA256: 8bb6ba567b288fb0ac71a70fd17041b2503df119e01998d76fdad372570bf849
|
|
33
33
|
permissions:
|
|
34
34
|
id-token: write # PyPI Trusted Publishing (OIDC) — no API token needed
|
|
@@ -9,6 +9,93 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.25.0] - 2026-11-01
|
|
13
|
+
|
|
14
|
+
### Changed (dashboard-breaking) — `babelqueue.otel` follows the OpenTelemetry messaging semantic conventions v1.44.0 (ADR-0038)
|
|
15
|
+
- **Span names change with no overlap:** `publish <urn>` becomes `send <queue>` and
|
|
16
|
+
`process <urn>` becomes `process <queue>` (the bare `send` / `process` when there is no queue).
|
|
17
|
+
The URN is never in the span name now (it is unbounded); it is the `babelqueue.urn` attribute.
|
|
18
|
+
The producer's `messaging.destination.name` is the queue the message is published to (it was the
|
|
19
|
+
URN), so a producer span and its consumer span share one destination. **Saved queries and
|
|
20
|
+
dashboards that filter on the old span names or on the producer destination must be updated.**
|
|
21
|
+
- **`messaging.system` is now the broker when you pass it.** `wrap_handler(..., system="kafka")` and
|
|
22
|
+
`publish(..., system="kafka")` (`redis`, `rabbitmq`, `aws_sqs`, `servicebus`, `pulsar`, `kafka`,
|
|
23
|
+
`activemq`; any other value is written as given). **Omitting `system` keeps `babelqueue`, which is
|
|
24
|
+
deprecated** (a one-time warning is logged); what an absent `system` means after the deprecation
|
|
25
|
+
window is not decided yet, so pass it now.
|
|
26
|
+
- New span attributes: `messaging.operation.type` / `messaging.operation.name` (`send` or
|
|
27
|
+
`process`), `babelqueue.urn`, `babelqueue.schema_version`, `babelqueue.attempts` and, on a failure,
|
|
28
|
+
`error.type` (`module.QualName` of the exception, the bare name for builtins) with an explicit
|
|
29
|
+
`ERROR` status whose description is the exception message. `consumer_group="..."` on
|
|
30
|
+
`wrap_handler` writes `messaging.consumer.group.name`.
|
|
31
|
+
- **Dual-emit for this MINOR only:** the deprecated `messaging.operation` (`publish` / `process`) and
|
|
32
|
+
`messaging.babelqueue.attempts` are still written next to their replacements. **The next MINOR
|
|
33
|
+
removes both.**
|
|
34
|
+
- `babelqueue.otel.SEMCONV_VERSION` (`1.44.0`) and `SEMCONV_SCHEMA_URL` (`https://opentelemetry.io/schemas/1.44.0`)
|
|
35
|
+
are public; create the tracer and meter with the schema URL
|
|
36
|
+
(`trace.get_tracer("orders", schema_url=otel.SEMCONV_SCHEMA_URL)`).
|
|
37
|
+
- A `trace_id` that is not a UUID is still preserved byte for byte and hashed for the OTel TraceID
|
|
38
|
+
(`trace_id_of` is unchanged); `wrap_handler` now logs a warning once per distinct value
|
|
39
|
+
(message-envelope.md 5.1, ADR-0038 section 7). Nothing is rejected or regenerated.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- **Standard metrics (ADR-0039), opt-in.** Pass `meter=` to `wrap_handler` and `publish` to record
|
|
43
|
+
`messaging.process.duration` (histogram, `s`, semconv bucket advice where the API supports it),
|
|
44
|
+
`messaging.client.consumed.messages`, `messaging.client.sent.messages` and `babelqueue.retries`
|
|
45
|
+
(counters, `{message}`; `retries` counts a delivery with `attempts > 0`). The attributes are
|
|
46
|
+
`messaging.system`, `messaging.operation.name`, `messaging.destination.name` (when known) and
|
|
47
|
+
`error.type` (on failure); never the URN, an id, the attempt count or a dead-letter reason. With no
|
|
48
|
+
`meter` nothing is recorded.
|
|
49
|
+
- **`babelqueue.dlq.messages` and the dead-letter observer seam (ADR-0039 section 5).**
|
|
50
|
+
`BabelQueue(..., dead_letter_observer=callback)` calls `callback(DeadLetterEvent(queue,
|
|
51
|
+
dead_letter_queue, reason))` once per dead-letter the runtime carried out, after the DLQ write is
|
|
52
|
+
confirmed (`BabelQueue._write_dead_letter`, the one chokepoint for exhausted retries, an unknown
|
|
53
|
+
URN, a poison body and a version-gated raw body) or after an SDK-initiated native dead-letter. A
|
|
54
|
+
failed write, a native no-op (SQS) and a broker-side dead-letter are not reported; an exception
|
|
55
|
+
raised by the observer is logged and never changes how the message is settled.
|
|
56
|
+
`otel.dead_letter_observer(meter, system=...)` is the observer that counts the metric.
|
|
57
|
+
New exports: `DeadLetterEvent`, `DeadLetterObserver`, and the `NativeDeadLetterSettler` protocol.
|
|
58
|
+
- **`NativeDeadLetterSettler`**: the optional native-settle variant
|
|
59
|
+
`settle_native_dead_letter(message, reason, description) -> bool` next to `NativeSettler`
|
|
60
|
+
(unchanged). When a transport implements it the runtime prefers it and reports a dead-letter only
|
|
61
|
+
on `True`; `AsbTransport` implements it, the SQS no-op does not.
|
|
62
|
+
- **`BabelQueue.publish_envelope(urn, data, *, queue=None, trace_id=None, headers=None)`** returns a
|
|
63
|
+
`PublishResult(envelope, error)` pair instead of raising. After a transport failure it still
|
|
64
|
+
carries the envelope that was handed to the transport (so `otel.publish` can put `meta.id` and
|
|
65
|
+
`trace_id` on a failed span); after a `make` or `encode` failure `envelope` is `None`. `publish`
|
|
66
|
+
and `publish_with_headers` delegate to it and behave exactly as before (an empty `queue` still
|
|
67
|
+
resolves to the default queue). `otel.publish` falls back to a duck-typed `app.publish(...)` for
|
|
68
|
+
objects that do not have it.
|
|
69
|
+
- Conformance **1.9.0** is vendored; the `span-shape`, `trace-id` and `metrics` sections (and the six
|
|
70
|
+
new `traceparent` cases) run and pass.
|
|
71
|
+
- **W3C `traceparent` / `tracestate` on the Kafka, Pulsar, Artemis and Azure Service Bus
|
|
72
|
+
transports (ADR-0028; broker-bindings 4.3, 5.2, 6.3, 7.2).** Each transport now implements the
|
|
73
|
+
`HeaderPublisher` capability: `publish_with_headers(queue, body, headers)` carries the headers
|
|
74
|
+
out of band beside the `bq-*` / `bq_*` projection (Kafka record headers, Pulsar message
|
|
75
|
+
properties, Artemis AMQP 1.0 application-properties, Azure Service Bus application properties).
|
|
76
|
+
The projection wins a key collision, and the body is byte-identical to a plain `publish`.
|
|
77
|
+
`pop` surfaces every non-projection header on `ReceivedMessage.headers` (so
|
|
78
|
+
`babelqueue.otel.wrap_handler` links the consumer span to the producer's across these brokers);
|
|
79
|
+
the projection keys themselves are never leaked, and `bq-replay-bypass` (ADR-0027) passes
|
|
80
|
+
through. A Kafka retry parks the consumed record's `traceparent` / `tracestate` on the tier topic
|
|
81
|
+
(the re-inject consumer already keeps every header except `bq-delay` / `bq-original-topic`), so
|
|
82
|
+
a retried message stays in the original trace. Retry tier names, `retry_tiers_ms` and the
|
|
83
|
+
poison / DLQ shapes are unchanged. `babelqueue.headers.out_of_band_headers` is the shared helper.
|
|
84
|
+
- **`RedrivePublisher` capability and `redrive(bypass=True)` on the header-capable transports.**
|
|
85
|
+
Kafka, Pulsar, Artemis and Azure Service Bus becoming `HeaderPublisher`s means
|
|
86
|
+
`redrive(..., bypass=True)` now really stamps the `bq-replay-bypass` marker on them
|
|
87
|
+
(`RedriveItem.bypassed` is `True`; it was `False` before, because the transports could not carry
|
|
88
|
+
headers), and the write goes through the transport's header-carrying path. So that this path
|
|
89
|
+
loses nothing the ordinary redrive write keeps, `Kafka` and `Pulsar` implement the new optional
|
|
90
|
+
`publish_redriven(queue, body, headers, route_urn, key)`: Kafka's is the broker-confirmed settle
|
|
91
|
+
write (a delivery failure raises `KafkaDeliveryError`, the DLQ record is restored and not
|
|
92
|
+
committed past) and carries the DLQ record key unchanged; Pulsar's pins `bq-job` to the
|
|
93
|
+
dead-lettered delivery's routing property. `redrive` uses it when the transport has it.
|
|
94
|
+
- **`AsbTransport.settle_native_dead_letter(message, reason, description) -> bool`** (ADR-0039
|
|
95
|
+
section 5.1): the optional native-settle variant that reports what it did. It performs the same
|
|
96
|
+
settle as `settle_native` (which is unchanged) and returns `True` when the receiver's
|
|
97
|
+
`dead_letter_message` ran, `False` when it only abandoned the lock or had no handle.
|
|
98
|
+
|
|
12
99
|
## [1.24.0] - 2026-10-26
|
|
13
100
|
|
|
14
101
|
Implements conformance **1.8.0** (vendored from the `v1.8.0` release asset, verified with
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.25.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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.25.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"
|
|
@@ -15,6 +15,7 @@ from . import dead_letter, gdpr, headers, idempotency, outbox, redrive, replay
|
|
|
15
15
|
from .app import BabelQueue
|
|
16
16
|
from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
|
|
17
17
|
from .contracts import HasTraceId, PolyglotMessage
|
|
18
|
+
from .dead_letter import DeadLetterEvent, DeadLetterObserver, NativeDeadLetterSettler
|
|
18
19
|
from .gdpr import Cipher, protect, unprotect
|
|
19
20
|
from .headers import headers_from_context
|
|
20
21
|
from .idempotency import IdempotencyStore, InMemoryStore
|
|
@@ -39,13 +40,14 @@ from .transport import (
|
|
|
39
40
|
NativeSettler,
|
|
40
41
|
ReceivedMessage,
|
|
41
42
|
Redeliverer,
|
|
43
|
+
RedrivePublisher,
|
|
42
44
|
Rewinder,
|
|
43
45
|
RoutedPublisher,
|
|
44
46
|
SettlePublisher,
|
|
45
47
|
Transport,
|
|
46
48
|
)
|
|
47
49
|
|
|
48
|
-
__version__ = "1.
|
|
50
|
+
__version__ = "1.25.0"
|
|
49
51
|
|
|
50
52
|
__all__ = [
|
|
51
53
|
"BabelQueue",
|
|
@@ -64,7 +66,11 @@ __all__ = [
|
|
|
64
66
|
"DeadLetterWriter",
|
|
65
67
|
"HeaderPublisher",
|
|
66
68
|
"NativeSettler",
|
|
69
|
+
"NativeDeadLetterSettler",
|
|
70
|
+
"DeadLetterEvent",
|
|
71
|
+
"DeadLetterObserver",
|
|
67
72
|
"Redeliverer",
|
|
73
|
+
"RedrivePublisher",
|
|
68
74
|
"Rewinder",
|
|
69
75
|
"RoutedPublisher",
|
|
70
76
|
"SettlePublisher",
|
|
@@ -32,10 +32,11 @@ import random
|
|
|
32
32
|
import signal
|
|
33
33
|
import threading
|
|
34
34
|
from types import FrameType
|
|
35
|
-
from typing import Any, Callable, Dict, Mapping, Optional
|
|
35
|
+
from typing import Any, Callable, Dict, Mapping, NamedTuple, Optional
|
|
36
36
|
|
|
37
37
|
from . import dead_letter
|
|
38
38
|
from .codec import EnvelopeCodec, has_forbidden_keys, parse_envelope
|
|
39
|
+
from .dead_letter import DeadLetterEvent, DeadLetterObserver, NativeDeadLetterSettler
|
|
39
40
|
from ._poison_record import build_poison_record
|
|
40
41
|
from .exceptions import BabelQueueError, UnknownUrnError
|
|
41
42
|
from .headers import _headers_scope
|
|
@@ -101,6 +102,19 @@ class _ShutdownDeadline(BaseException):
|
|
|
101
102
|
A ``BaseException`` so the dispatch-level ``except Exception`` does not swallow it."""
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
class PublishResult(NamedTuple):
|
|
106
|
+
"""The outcome of :meth:`BabelQueue.publish_envelope`: ``(envelope, error)``.
|
|
107
|
+
|
|
108
|
+
``envelope`` is the envelope that was handed to the transport (``None`` when building or
|
|
109
|
+
encoding it failed, so nothing was ever handed over); ``error`` is the exception that stopped
|
|
110
|
+
the publish (``None`` on success). A failed transport publish therefore still yields the
|
|
111
|
+
envelope that was attempted, which is what an observability wrapper needs.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
envelope: Optional[Dict[str, Any]]
|
|
115
|
+
error: Optional[Exception]
|
|
116
|
+
|
|
117
|
+
|
|
104
118
|
class BabelQueue:
|
|
105
119
|
def __init__(
|
|
106
120
|
self,
|
|
@@ -119,6 +133,7 @@ class BabelQueue:
|
|
|
119
133
|
retry_jitter: str = "none",
|
|
120
134
|
unknown_urn_release_delay: float = 0.0,
|
|
121
135
|
shutdown_timeout: float = 30.0,
|
|
136
|
+
dead_letter_observer: Optional[DeadLetterObserver] = None,
|
|
122
137
|
) -> None:
|
|
123
138
|
"""
|
|
124
139
|
``retry_backoff`` / ``unknown_urn_release_delay`` are the seconds a released message
|
|
@@ -139,6 +154,12 @@ class BabelQueue:
|
|
|
139
154
|
before releasing its message. Keep it above your longest handler run: a released
|
|
140
155
|
message is redelivered and its handler runs again, and on brokers that count
|
|
141
156
|
deliveries (SQS ``ApproximateReceiveCount``) the release also consumes an attempt.
|
|
157
|
+
|
|
158
|
+
``dead_letter_observer`` (ADR-0039 section 5.1) is an optional callback
|
|
159
|
+
:data:`~babelqueue.dead_letter.DeadLetterObserver` the runtime calls once per dead-letter it
|
|
160
|
+
carried out itself, after the DLQ write (or the native dead-letter) is confirmed. A failed
|
|
161
|
+
write, a native no-op and a broker-side dead-letter are not reported. An exception it
|
|
162
|
+
raises is logged and never changes how the message is settled.
|
|
142
163
|
"""
|
|
143
164
|
self.transport = transport if transport is not None else make_transport(broker_url)
|
|
144
165
|
self.queue = queue
|
|
@@ -157,6 +178,7 @@ class BabelQueue:
|
|
|
157
178
|
self.retry_jitter = retry_jitter
|
|
158
179
|
self.unknown_urn_release_delay = float(unknown_urn_release_delay)
|
|
159
180
|
self.shutdown_timeout = float(shutdown_timeout)
|
|
181
|
+
self.dead_letter_observer = dead_letter_observer
|
|
160
182
|
self._handlers: Dict[str, Handler] = {}
|
|
161
183
|
self._stop = threading.Event()
|
|
162
184
|
self._rewind_pause = 1.0 # seconds to wait before a rewound gated message is re-polled
|
|
@@ -178,10 +200,7 @@ class BabelQueue:
|
|
|
178
200
|
trace_id: Optional[str] = None,
|
|
179
201
|
) -> str:
|
|
180
202
|
"""Publish a message; returns its id (``meta.id``)."""
|
|
181
|
-
|
|
182
|
-
envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
|
|
183
|
-
self.transport.publish(target, EnvelopeCodec.encode(envelope))
|
|
184
|
-
return envelope["meta"]["id"]
|
|
203
|
+
return self._published_id(self.publish_envelope(urn, data, queue=queue, trace_id=trace_id))
|
|
185
204
|
|
|
186
205
|
def publish_with_headers(
|
|
187
206
|
self,
|
|
@@ -205,14 +224,60 @@ class BabelQueue:
|
|
|
205
224
|
exactly as :func:`~babelqueue.redrive.redrive` degrades. Passing empty headers is
|
|
206
225
|
equivalent to :meth:`publish`, so callers need not branch on transport capability.
|
|
207
226
|
"""
|
|
227
|
+
return self._published_id(
|
|
228
|
+
self.publish_envelope(urn, data, queue=queue, trace_id=trace_id, headers=headers)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def publish_envelope(
|
|
232
|
+
self,
|
|
233
|
+
urn: str,
|
|
234
|
+
data: Mapping[str, Any],
|
|
235
|
+
*,
|
|
236
|
+
queue: Optional[str] = None,
|
|
237
|
+
trace_id: Optional[str] = None,
|
|
238
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
239
|
+
) -> PublishResult:
|
|
240
|
+
"""Publish like :meth:`publish` / :meth:`publish_with_headers`, but **return** the outcome
|
|
241
|
+
instead of raising: a :class:`PublishResult` ``(envelope, error)`` pair.
|
|
242
|
+
|
|
243
|
+
It is the one place that builds and sends the envelope. The two plain publish methods
|
|
244
|
+
delegate to it and re-raise ``error``, so their behaviour is unchanged (an empty ``queue``
|
|
245
|
+
resolves to :attr:`queue`, exactly as there). The difference is what a failure leaves
|
|
246
|
+
behind:
|
|
247
|
+
|
|
248
|
+
* ``make`` or ``encode`` failed (e.g. a blank URN, or ``data`` that is not JSON
|
|
249
|
+
serializable): ``envelope`` is ``None``, because nothing was ever handed to the
|
|
250
|
+
transport; ``error`` is the exception.
|
|
251
|
+
* the transport failed: ``envelope`` is the envelope that was handed to it (``meta.id``,
|
|
252
|
+
``trace_id`` and ``meta.queue`` are readable), and ``error`` is the exception.
|
|
253
|
+
* success: ``(envelope, None)``.
|
|
254
|
+
|
|
255
|
+
``headers`` are the out-of-band transport headers of :meth:`publish_with_headers`
|
|
256
|
+
(empty or ``None`` means a plain publish). The observability wrapper
|
|
257
|
+
:func:`babelqueue.otel.publish` uses it to read the produced identity on the failure path.
|
|
258
|
+
Only ``Exception`` is captured; ``KeyboardInterrupt`` and friends propagate.
|
|
259
|
+
"""
|
|
208
260
|
target = queue or self.queue
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
261
|
+
try:
|
|
262
|
+
envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
|
|
263
|
+
body = EnvelopeCodec.encode(envelope)
|
|
264
|
+
except Exception as exc: # noqa: BLE001 - returned to the caller, which re-raises it
|
|
265
|
+
return PublishResult(None, exc)
|
|
266
|
+
try:
|
|
267
|
+
if headers and isinstance(self.transport, HeaderPublisher):
|
|
268
|
+
self.transport.publish_with_headers(target, body, dict(headers))
|
|
269
|
+
else:
|
|
270
|
+
self.transport.publish(target, body)
|
|
271
|
+
except Exception as exc: # noqa: BLE001 - returned to the caller, which re-raises it
|
|
272
|
+
return PublishResult(envelope, exc)
|
|
273
|
+
return PublishResult(envelope, None)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _published_id(result: PublishResult) -> str:
|
|
277
|
+
if result.error is not None:
|
|
278
|
+
raise result.error
|
|
279
|
+
assert result.envelope is not None # no error means the envelope was built and sent
|
|
280
|
+
return result.envelope["meta"]["id"]
|
|
216
281
|
|
|
217
282
|
# -- Register handlers --------------------------------------------------
|
|
218
283
|
|
|
@@ -504,16 +569,46 @@ class BabelQueue:
|
|
|
504
569
|
mismatch does not flip to the body URN on the next attempt or in the DLQ."""
|
|
505
570
|
publish_keeping_route(self.transport, queue, body, received.route_urn, received.key)
|
|
506
571
|
|
|
507
|
-
def _write_dead_letter(
|
|
572
|
+
def _write_dead_letter(
|
|
573
|
+
self,
|
|
574
|
+
received: ReceivedMessage,
|
|
575
|
+
queue: str,
|
|
576
|
+
body: str,
|
|
577
|
+
*,
|
|
578
|
+
source_queue: Optional[str] = None,
|
|
579
|
+
reason: str = "",
|
|
580
|
+
) -> None:
|
|
508
581
|
"""Write the dead-letter record ``body`` to ``queue`` and settle ``received``.
|
|
509
582
|
|
|
510
583
|
A :class:`~babelqueue.transport.DeadLetterWriter` transport does both as one guarded step
|
|
511
|
-
(a failed write propagates, never acks); otherwise publish, then acknowledge.
|
|
584
|
+
(a failed write propagates, never acks); otherwise publish, then acknowledge.
|
|
585
|
+
|
|
586
|
+
The single chokepoint of every BabelQueue DLQ write (ADR-0039 section 5.2): once the write
|
|
587
|
+
is confirmed the dead-letter observer is told (``source_queue`` defaults to the queue
|
|
588
|
+
``received`` came from, ``reason`` is empty for a raw body). A failed write raises before
|
|
589
|
+
that, so the observer is never called for it."""
|
|
512
590
|
if isinstance(self.transport, DeadLetterWriter):
|
|
513
591
|
self.transport.dead_letter(received, queue, body)
|
|
592
|
+
else:
|
|
593
|
+
self._publish_for(received, queue, body)
|
|
594
|
+
self._ack_settled(received)
|
|
595
|
+
self._notify_dead_letter(source_queue or received.queue, queue, reason)
|
|
596
|
+
|
|
597
|
+
def _notify_dead_letter(self, queue: str, dead_letter_queue: str, reason: str) -> None:
|
|
598
|
+
"""Tell :attr:`dead_letter_observer` about a confirmed dead-letter. A raising observer is
|
|
599
|
+
logged and swallowed: it must never change the settle outcome (ADR-0039 section 5.1)."""
|
|
600
|
+
observer = self.dead_letter_observer
|
|
601
|
+
if observer is None:
|
|
514
602
|
return
|
|
515
|
-
|
|
516
|
-
|
|
603
|
+
try:
|
|
604
|
+
observer(DeadLetterEvent(queue=queue, dead_letter_queue=dead_letter_queue, reason=reason))
|
|
605
|
+
except Exception: # noqa: BLE001 - an observer must never affect settlement
|
|
606
|
+
logger.error(
|
|
607
|
+
"The dead-letter observer failed for a message from queue %r; the dead-letter "
|
|
608
|
+
"itself was already settled and is unaffected.",
|
|
609
|
+
queue,
|
|
610
|
+
exc_info=True,
|
|
611
|
+
)
|
|
517
612
|
|
|
518
613
|
def _ack_settled(self, received: ReceivedMessage) -> None:
|
|
519
614
|
"""Acknowledge a message whose outcome is already final (handled, dead-lettered,
|
|
@@ -540,8 +635,13 @@ class BabelQueue:
|
|
|
540
635
|
"""Hand ``received`` to the transport's :class:`~babelqueue.transport.NativeSettler`
|
|
541
636
|
capability and report whether it did: ``False`` means the transport has none and the caller
|
|
542
637
|
keeps its previous settle. The event is logged and never retried by the SDK; a failing call
|
|
543
|
-
is logged too and leaves the message unacknowledged (the broker redelivers it).
|
|
544
|
-
|
|
638
|
+
is logged too and leaves the message unacknowledged (the broker redelivers it).
|
|
639
|
+
|
|
640
|
+
A transport that also implements :class:`~babelqueue.dead_letter.NativeDeadLetterSettler`
|
|
641
|
+
is settled through that variant instead, and the dead-letter observer is told only when it
|
|
642
|
+
returns ``True`` (a native dead-letter the SDK carried out, ADR-0039 section 5.1)."""
|
|
643
|
+
transport = self.transport
|
|
644
|
+
if not isinstance(transport, (NativeDeadLetterSettler, NativeSettler)):
|
|
545
645
|
return False
|
|
546
646
|
logger.warning(
|
|
547
647
|
"Left to the broker's native mechanism (not acknowledged, not retried): queue %r, "
|
|
@@ -549,7 +649,14 @@ class BabelQueue:
|
|
|
549
649
|
received.queue, received.route_urn, reason,
|
|
550
650
|
)
|
|
551
651
|
try:
|
|
552
|
-
|
|
652
|
+
if isinstance(transport, NativeDeadLetterSettler):
|
|
653
|
+
# The optional variant (ADR-0039 section 5.1) says whether the broker really
|
|
654
|
+
# dead-lettered the message: only a literal ``True`` is a dead-letter the SDK
|
|
655
|
+
# carried out (an abandon, or the SQS-style no-op, is not).
|
|
656
|
+
if transport.settle_native_dead_letter(received, reason, description) is True:
|
|
657
|
+
self._notify_dead_letter(received.queue, "", "")
|
|
658
|
+
else:
|
|
659
|
+
transport.settle_native(received, reason, description)
|
|
553
660
|
except Exception: # noqa: BLE001 - reported, never kills the loop
|
|
554
661
|
logger.error(
|
|
555
662
|
"Native settle of a message on queue %r failed; it stays unacknowledged and will "
|
|
@@ -735,7 +842,7 @@ class BabelQueue:
|
|
|
735
842
|
self._ack_settled(received)
|
|
736
843
|
return
|
|
737
844
|
if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
|
|
738
|
-
self._settle_version_gated(received, always=True)
|
|
845
|
+
self._settle_version_gated(received, always=True, reason="poison")
|
|
739
846
|
return
|
|
740
847
|
if self._retry_tiers_unavailable():
|
|
741
848
|
# broker-bindings §6.5 / ADR-0036 decision 5: no retry tier, no dead-lettering and not
|
|
@@ -836,7 +943,10 @@ class BabelQueue:
|
|
|
836
943
|
exception=BabelQueueError.__name__,
|
|
837
944
|
)["dead_letter"]
|
|
838
945
|
self._write_dead_letter(
|
|
839
|
-
received,
|
|
946
|
+
received,
|
|
947
|
+
self._dead_letter_target(received),
|
|
948
|
+
build_poison_record(received.body, block),
|
|
949
|
+
reason="poison",
|
|
840
950
|
)
|
|
841
951
|
|
|
842
952
|
def _dead_letter_target(self, received: ReceivedMessage) -> str:
|
|
@@ -850,11 +960,16 @@ class BabelQueue:
|
|
|
850
960
|
return self.transport.dead_letter_queue_name(received.queue, self.dead_letter_suffix)
|
|
851
961
|
return received.queue + (".dlq" if self.dead_letter_suffix is None else self.dead_letter_suffix)
|
|
852
962
|
|
|
853
|
-
def _settle_version_gated(
|
|
963
|
+
def _settle_version_gated(
|
|
964
|
+
self, received: ReceivedMessage, always: bool = False, reason: str = ""
|
|
965
|
+
) -> None:
|
|
854
966
|
"""Dead-letter a version-gated delivery directly (no retry). With no DLQ set it is left to the
|
|
855
967
|
transport's native mechanism when the transport has the :class:`~babelqueue.transport.NativeSettler`
|
|
856
968
|
capability (SQS, Azure Service Bus; ADR-0037 decision 1 rule 3) and dropped otherwise.
|
|
857
969
|
|
|
970
|
+
``reason`` is what the dead-letter observer is told (``""`` for a raw version-gated body;
|
|
971
|
+
``"poison"`` for a Kafka poison record, whose DLQ record the transport wraps with that reason).
|
|
972
|
+
|
|
858
973
|
``always`` writes the DLQ record in every configuration, ignoring ``dead_letter_enabled``.
|
|
859
974
|
A Kafka poison record reaches it with the DLQ already on (:meth:`_route_poison`). The Redis
|
|
860
975
|
poison reservation does not come through here: it has its own ADR-0035 record
|
|
@@ -866,7 +981,9 @@ class BabelQueue:
|
|
|
866
981
|
(see :meth:`dispatch`), never retried.
|
|
867
982
|
"""
|
|
868
983
|
if always or self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
|
|
869
|
-
self._write_dead_letter(
|
|
984
|
+
self._write_dead_letter(
|
|
985
|
+
received, self._dead_letter_target(received), received.body, reason=reason
|
|
986
|
+
)
|
|
870
987
|
return
|
|
871
988
|
# No DLQ to hold it (broker-bindings §5.7 rule 3). A transport with a native mechanism (SQS,
|
|
872
989
|
# Azure Service Bus) leaves it to that mechanism instead of dropping it (ADR-0037 decision 1).
|
|
@@ -927,7 +1044,11 @@ class BabelQueue:
|
|
|
927
1044
|
exception=(type(exc).__name__ if exc is not None else None),
|
|
928
1045
|
)
|
|
929
1046
|
self._write_dead_letter(
|
|
930
|
-
received,
|
|
1047
|
+
received,
|
|
1048
|
+
self._dead_letter_target(received),
|
|
1049
|
+
EnvelopeCodec.encode(annotated),
|
|
1050
|
+
source_queue=original_queue,
|
|
1051
|
+
reason=reason,
|
|
931
1052
|
)
|
|
932
1053
|
|
|
933
1054
|
|
|
@@ -21,6 +21,13 @@ deliveries. (The Java JMS binding reads ``JMSXDeliveryCount`` which is 1-based a
|
|
|
21
21
|
arriving at the same 0-based ``attempts``.) A message left un-accepted is redelivered
|
|
22
22
|
(at-least-once).
|
|
23
23
|
|
|
24
|
+
Out-of-band transport headers (a W3C ``traceparent`` for cross-hop span linkage, ADR-0028) ride as
|
|
25
|
+
extra AMQP 1.0 application-properties (string-valued, hyphen-free names) beside the ``bq_*`` projection:
|
|
26
|
+
:meth:`ArtemisTransport.publish_with_headers` writes them (the projection wins a collision) and
|
|
27
|
+
:meth:`~ArtemisTransport.pop` surfaces every non-projection application-property on
|
|
28
|
+
:attr:`~babelqueue.transport.ReceivedMessage.headers`; the ``bq_*`` projection keys are not leaked. The
|
|
29
|
+
body is untouched (GR-1).
|
|
30
|
+
|
|
24
31
|
This implements §7 of the broker-bindings contract. The envelope is unchanged
|
|
25
32
|
(``schema_version`` stays 1); Apache ActiveMQ Artemis is purely additive.
|
|
26
33
|
|
|
@@ -38,6 +45,7 @@ from typing import Any, Dict, Optional
|
|
|
38
45
|
from urllib.parse import quote
|
|
39
46
|
|
|
40
47
|
from .codec import ASCII_WHITESPACE, SCHEMA_VERSION, EnvelopeCodec, parse_envelope
|
|
48
|
+
from .headers import out_of_band_headers
|
|
41
49
|
from .transport import ReceivedMessage, Transport, truncate_declared_version
|
|
42
50
|
|
|
43
51
|
_logger = logging.getLogger("babelqueue.artemis")
|
|
@@ -157,12 +165,14 @@ class ArtemisTransport(Transport):
|
|
|
157
165
|
except (TypeError, ValueError): # pragma: no cover - defensive
|
|
158
166
|
return None
|
|
159
167
|
|
|
160
|
-
def _message(self, body: str) -> Any:
|
|
161
|
-
"""Build the proton Message projecting the §7 JMS-readable metadata.
|
|
168
|
+
def _message(self, body: str, headers: Optional[Dict[str, str]] = None) -> Any:
|
|
169
|
+
"""Build the proton Message projecting the §7 JMS-readable metadata. ``headers`` are
|
|
170
|
+
out-of-band application-properties (ADR-0028) merged *under* the ``bq_*`` projection."""
|
|
162
171
|
from proton import Message, symbol
|
|
163
172
|
|
|
164
173
|
message: Any = Message(body=body)
|
|
165
|
-
props =
|
|
174
|
+
props = out_of_band_headers(headers)
|
|
175
|
+
props.update(self._projection(body))
|
|
166
176
|
if props:
|
|
167
177
|
message.properties = props
|
|
168
178
|
correlation_id = self._correlation_id(body)
|
|
@@ -242,6 +252,13 @@ class ArtemisTransport(Transport):
|
|
|
242
252
|
def publish(self, queue: str, body: str) -> None:
|
|
243
253
|
self._sender(queue).send(self._message(body))
|
|
244
254
|
|
|
255
|
+
def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
|
|
256
|
+
""":meth:`publish` with out-of-band ``headers`` (a W3C ``traceparent``, the replay-bypass
|
|
257
|
+
marker ...) carried as extra AMQP 1.0 application-properties beside the ``bq_*`` projection
|
|
258
|
+
(:class:`~babelqueue.transport.HeaderPublisher`, ADR-0028, broker-bindings 7.2). The
|
|
259
|
+
projection wins a key collision and the body is byte-identical to :meth:`publish`."""
|
|
260
|
+
self._sender(queue).send(self._message(body, headers))
|
|
261
|
+
|
|
245
262
|
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
246
263
|
wait = self._receive_timeout_millis / 1000.0
|
|
247
264
|
if timeout and timeout > 0:
|
|
@@ -276,7 +293,12 @@ class ArtemisTransport(Transport):
|
|
|
276
293
|
route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
|
|
277
294
|
)
|
|
278
295
|
body = self._reconcile(self._payload(message), self._delivery_count(message))
|
|
279
|
-
return ReceivedMessage(
|
|
296
|
+
return ReceivedMessage(
|
|
297
|
+
body=body,
|
|
298
|
+
queue=queue,
|
|
299
|
+
handle=receiver,
|
|
300
|
+
headers=out_of_band_headers(getattr(message, "properties", None)),
|
|
301
|
+
)
|
|
280
302
|
|
|
281
303
|
def ack(self, message: ReceivedMessage) -> None:
|
|
282
304
|
if message.handle is None:
|
|
@@ -16,6 +16,13 @@ A version-gated message (``bq-schema-version`` the consumer does not support) or
|
|
|
16
16
|
dead-lettering off, not completed: :meth:`AsbTransport.settle_native` dead-letters it natively into
|
|
17
17
|
``$DeadLetterQueue`` (ADR-0037).
|
|
18
18
|
|
|
19
|
+
Out-of-band transport headers (a W3C ``traceparent`` for cross-hop span linkage, ADR-0028) ride as
|
|
20
|
+
extra application properties (string-valued) beside the ``bq-*`` projection:
|
|
21
|
+
:meth:`AsbTransport.publish_with_headers` writes them (the projection wins a collision) and
|
|
22
|
+
:meth:`~AsbTransport.pop` surfaces every non-projection application property on
|
|
23
|
+
:attr:`~babelqueue.transport.ReceivedMessage.headers`; the ``bq-*`` projection keys are not leaked.
|
|
24
|
+
The body is untouched (GR-1).
|
|
25
|
+
|
|
19
26
|
This implements §4 of the broker-bindings contract. The envelope is unchanged
|
|
20
27
|
(``schema_version`` stays 1); Azure Service Bus is purely additive.
|
|
21
28
|
|
|
@@ -33,6 +40,7 @@ from typing import Any, Dict, Optional
|
|
|
33
40
|
from urllib.parse import quote, urlsplit
|
|
34
41
|
|
|
35
42
|
from .codec import ASCII_WHITESPACE, SCHEMA_VERSION, EnvelopeCodec, parse_envelope
|
|
43
|
+
from .headers import out_of_band_headers
|
|
36
44
|
from .transport import ReceivedMessage, Transport, truncate_declared_version
|
|
37
45
|
|
|
38
46
|
_logger = logging.getLogger("babelqueue.asb")
|
|
@@ -228,11 +236,23 @@ class AsbTransport(Transport):
|
|
|
228
236
|
# -- Transport ----------------------------------------------------------
|
|
229
237
|
|
|
230
238
|
def publish(self, queue: str, body: str) -> None:
|
|
239
|
+
self._send(queue, body, None)
|
|
240
|
+
|
|
241
|
+
def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
|
|
242
|
+
""":meth:`publish` with out-of-band ``headers`` (a W3C ``traceparent``, the replay-bypass
|
|
243
|
+
marker ...) carried as extra string-valued application properties beside the ``bq-*``
|
|
244
|
+
projection (:class:`~babelqueue.transport.HeaderPublisher`, ADR-0028, broker-bindings 4.3).
|
|
245
|
+
The projection wins a key collision and the body is byte-identical to :meth:`publish`."""
|
|
246
|
+
self._send(queue, body, headers)
|
|
247
|
+
|
|
248
|
+
def _send(self, queue: str, body: str, headers: Optional[Dict[str, str]]) -> None:
|
|
231
249
|
from azure.servicebus import ServiceBusMessage
|
|
232
250
|
|
|
233
251
|
kwargs = self._projection(body)
|
|
234
|
-
|
|
235
|
-
|
|
252
|
+
properties: Dict[str, Any] = dict(out_of_band_headers(headers))
|
|
253
|
+
properties.update(kwargs.get("application_properties") or {})
|
|
254
|
+
if properties:
|
|
255
|
+
kwargs["application_properties"] = _amqp_typed(properties)
|
|
236
256
|
message = ServiceBusMessage(body, **kwargs)
|
|
237
257
|
self._sender(queue).send_messages(message)
|
|
238
258
|
|
|
@@ -266,7 +286,12 @@ class AsbTransport(Transport):
|
|
|
266
286
|
route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
|
|
267
287
|
)
|
|
268
288
|
body = self._reconcile(str(message), getattr(message, "delivery_count", None))
|
|
269
|
-
return ReceivedMessage(
|
|
289
|
+
return ReceivedMessage(
|
|
290
|
+
body=body,
|
|
291
|
+
queue=queue,
|
|
292
|
+
handle=message,
|
|
293
|
+
headers=out_of_band_headers(getattr(message, "application_properties", None)),
|
|
294
|
+
)
|
|
270
295
|
|
|
271
296
|
def ack(self, message: ReceivedMessage) -> None:
|
|
272
297
|
if message.handle is None:
|
|
@@ -297,14 +322,26 @@ class AsbTransport(Transport):
|
|
|
297
322
|
``$DeadLetterQueue``. Nothing is completed, re-published or retried, and the body is
|
|
298
323
|
unchanged. A receiver without ``dead_letter_message`` abandons the lock instead (also a
|
|
299
324
|
conformant native settle: ``MaxDeliveryCount`` takes over)."""
|
|
325
|
+
self.settle_native_dead_letter(message, reason, description)
|
|
326
|
+
|
|
327
|
+
def settle_native_dead_letter(
|
|
328
|
+
self, message: ReceivedMessage, reason: str, description: str
|
|
329
|
+
) -> bool:
|
|
330
|
+
""":meth:`settle_native` that also reports what it did (the optional native-settle variant
|
|
331
|
+
of ADR-0039 section 5.1): ``True`` when the receiver's ``dead_letter_message`` was called,
|
|
332
|
+
so the message really went to ``$DeadLetterQueue``, and ``False`` when it only abandoned
|
|
333
|
+
the lock (a receiver without ``dead_letter_message``) or had no handle to settle. The
|
|
334
|
+
runtime prefers it over :meth:`settle_native` and counts a dead-letter only on ``True``.
|
|
335
|
+
The settle itself is identical to :meth:`settle_native`, which stays as it was."""
|
|
300
336
|
if message.handle is None:
|
|
301
|
-
return
|
|
337
|
+
return False
|
|
302
338
|
receiver = self._receiver(message.queue)
|
|
303
339
|
dead_letter = getattr(receiver, "dead_letter_message", None)
|
|
304
340
|
if dead_letter is not None:
|
|
305
341
|
dead_letter(message.handle, reason=reason, error_description=description)
|
|
306
|
-
return
|
|
342
|
+
return True
|
|
307
343
|
self.abandon(message)
|
|
344
|
+
return False
|
|
308
345
|
|
|
309
346
|
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
310
347
|
for resource in (*self._senders.values(), *self._receivers.values(), self._client):
|