babelqueue 1.14.0__tar.gz → 1.15.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.14.0 → babelqueue-1.15.0}/CHANGELOG.md +59 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/PKG-INFO +2 -2
- {babelqueue-1.14.0 → babelqueue-1.15.0}/pyproject.toml +2 -2
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/__init__.py +10 -2
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/app.py +31 -5
- babelqueue-1.15.0/src/babelqueue/pulsar_transport.py +383 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/redrive.py +13 -9
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/transport.py +30 -0
- babelqueue-1.15.0/tests/test_app.py +303 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_pulsar_conformance.py +1 -1
- babelqueue-1.15.0/tests/test_pulsar_transport.py +648 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_redrive.py +38 -0
- babelqueue-1.14.0/src/babelqueue/pulsar_transport.py +0 -202
- babelqueue-1.14.0/tests/test_app.py +0 -125
- babelqueue-1.14.0/tests/test_pulsar_transport.py +0 -240
- {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/.gitignore +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/LICENSE +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/README.md +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/CONFORMANCE_VERSION +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_behaviour_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_celery.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_codec.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_django.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_headers.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_otel.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_replay.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_schema.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_sqs_transport.py +0 -0
|
@@ -9,6 +9,65 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.15.0] - 2026-10-10
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **`ReceivedMessage.route_urn` routing seam** (MINOR). An optional field a transport can set
|
|
16
|
+
when its broker carries the URN out of band. When non-empty `BabelQueue.dispatch()` looks the
|
|
17
|
+
handler up by it instead of the decoded body's URN; empty (the default for every existing
|
|
18
|
+
transport) keeps routing on the body. The body is never rewritten, so the handler, the retry
|
|
19
|
+
re-publish and the dead-letter record carry the producer's original body (including a `urn`
|
|
20
|
+
alias). No wire change.
|
|
21
|
+
- **`RoutedPublisher` optional transport capability** (MINOR). `publish_routed(queue, body,
|
|
22
|
+
route_urn)`. When a delivery has a `route_urn` and the transport implements it, the retry, release and dead-letter
|
|
23
|
+
writes, and `redrive`, use it so the broker's routing property keeps the original
|
|
24
|
+
value across attempts (no flip to the body URN on the second delivery). `PulsarTransport`
|
|
25
|
+
implements it (`bq-job` pinned; blank derives from the body). Others keep plain `publish`.
|
|
26
|
+
No wire change.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- `BabelQueue.dispatch()` applies `route_urn` only to a body that decodes; an undecodable body
|
|
30
|
+
stays on the poison / unknown-URN path instead of being routed to a handler.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **`on_unknown_urn=RELEASE` no longer loops a version-gated or poison message forever.**
|
|
34
|
+
`BabelQueue.dispatch()` re-published such a message unchanged on every delivery (`attempts` never
|
|
35
|
+
grew, so it never reached the DLQ). A delivery a transport version-gated (reserved
|
|
36
|
+
`unsupported-schema-version` `route_urn`, e.g. Pulsar `bq-schema-version`), an undecodable body
|
|
37
|
+
and a body naming no URN now take the bounded `FAIL` path under `RELEASE` (retry up to
|
|
38
|
+
`max_attempts`, then dead-letter or drop), matching the Go core (GR-5). A genuinely unknown URN
|
|
39
|
+
still releases verbatim. No public API or wire change. Note: as on the existing `FAIL` path, a
|
|
40
|
+
body that is not valid JSON is not carried verbatim through the retry / dead-letter re-publish
|
|
41
|
+
(the runtime re-encodes the envelope it could build, so the producer's original bytes are lost);
|
|
42
|
+
previously `RELEASE` re-published those bytes unchanged.
|
|
43
|
+
- **Pulsar consumer version-gates on `bq-schema-version` (broker-bindings §5.7 step 3).**
|
|
44
|
+
`PulsarTransport.pop()` now reads the `bq-schema-version` message property before decoding the
|
|
45
|
+
body. A missing or blank value changes nothing; "Blank" is the same set in every SDK: the empty string or only ASCII whitespace (space, `\t`, `\n`, `\v`, `\f`, `\r`); anything else (NBSP, U+001C-U+001F, U+0085, U+FEFF, U+3000, ...) is not blank and is rejected unless it is exactly the supported version. (`str.strip()` is not used: it also strips U+001C-U+001F, U+0085, NBSP, ...) a value other than the supported
|
|
46
|
+
`SCHEMA_VERSION` (`"2"`, `"x"`, `"01"`, ...) is not decoded: the delivery carries the reserved
|
|
47
|
+
`route_urn` `babelqueue:unsupported-schema-version:<value>`, which no handler owns, so the
|
|
48
|
+
runtime quarantines it through the existing unknown-URN retry / dead-letter path (the error
|
|
49
|
+
names the rejected version). The body is passed through untouched. No wire change. The route
|
|
50
|
+
is an in-process marker only: it also carries the delivery's original `bq-job`, and the retry /
|
|
51
|
+
dead-letter re-publish (`publish_routed`) writes that original `bq-job` (or, when the delivery
|
|
52
|
+
had none, the body's job) and the original raw `bq-schema-version` back, never the sentinel, so
|
|
53
|
+
a redrive meets the same gate. The gate also logs a `babelqueue.pulsar` warning naming the
|
|
54
|
+
rejected version and queue, including for a body that is not valid JSON (which the runtime
|
|
55
|
+
quarantines as a poison message whose error names the reserved route).
|
|
56
|
+
- **Pulsar consumer now follows broker-bindings §5.2 / §5.7.** `PulsarTransport.pop()` routes on
|
|
57
|
+
the `bq-job` message property (set on `ReceivedMessage.route_urn`; the body's URN is used only
|
|
58
|
+
when the property is absent or blank) and takes `attempts` from the authoritative
|
|
59
|
+
`bq-attempts` property (the body's value is used only when the property is absent or not a
|
|
60
|
+
plain ASCII-decimal integer that fits a 32-bit int), then applies `max(.., redelivery_count)`.
|
|
61
|
+
Previously only the body was read. The body's `job` / `urn` is **not** rewritten, so a
|
|
62
|
+
property/body mismatch no longer leaks into retries or the DLQ; `attempts` is written into the
|
|
63
|
+
body only when the body already holds an integer `attempts` and the value changes (a missing
|
|
64
|
+
or non-integer `attempts` is still rejected by `EnvelopeCodec.accepts`). When the property and
|
|
65
|
+
the body name different URNs the property wins and the new optional
|
|
66
|
+
`on_routing_mismatch(property_urn, body_urn, envelope)` constructor hook is called for an
|
|
67
|
+
accepted envelope, after attempts are reconciled (its exceptions are swallowed; it is separate
|
|
68
|
+
from error handling). A body without a URN is still rejected. No wire change
|
|
69
|
+
(`schema_version: 1`).
|
|
70
|
+
|
|
12
71
|
## [1.14.0] - 2026-10-03
|
|
13
72
|
|
|
14
73
|
### Added
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.15.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
|
|
7
7
|
Project-URL: Issues, https://github.com/BabelQueue/babelqueue-python/issues
|
|
8
8
|
Project-URL: Changelog, https://github.com/BabelQueue/babelqueue-python/blob/main/CHANGELOG.md
|
|
9
|
-
Author-email: Muhammet Şafak <info@muhammetsafak.com
|
|
9
|
+
Author-email: Muhammet Şafak <info@muhammetsafak.com>
|
|
10
10
|
License-Expression: MIT
|
|
11
11
|
License-File: LICENSE
|
|
12
12
|
Keywords: envelope,json,messaging,microservices,polyglot,queue
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.15.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"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
license-files = ["LICENSE"]
|
|
13
|
-
authors = [{ name = "Muhammet Şafak", email = "info@muhammetsafak.com
|
|
13
|
+
authors = [{ name = "Muhammet Şafak", email = "info@muhammetsafak.com" }]
|
|
14
14
|
keywords = ["queue", "polyglot", "microservices", "json", "envelope", "messaging"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Development Status :: 4 - Beta",
|
|
@@ -29,9 +29,16 @@ from .outbox import (
|
|
|
29
29
|
from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
|
|
30
30
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
31
31
|
from .routing import UnknownUrnStrategy
|
|
32
|
-
from .transport import
|
|
32
|
+
from .transport import (
|
|
33
|
+
HeaderPublisher,
|
|
34
|
+
InMemoryTransport,
|
|
35
|
+
ReceivedMessage,
|
|
36
|
+
Redeliverer,
|
|
37
|
+
RoutedPublisher,
|
|
38
|
+
Transport,
|
|
39
|
+
)
|
|
33
40
|
|
|
34
|
-
__version__ = "1.
|
|
41
|
+
__version__ = "1.15.0"
|
|
35
42
|
|
|
36
43
|
__all__ = [
|
|
37
44
|
"BabelQueue",
|
|
@@ -46,6 +53,7 @@ __all__ = [
|
|
|
46
53
|
"ReceivedMessage",
|
|
47
54
|
"HeaderPublisher",
|
|
48
55
|
"Redeliverer",
|
|
56
|
+
"RoutedPublisher",
|
|
49
57
|
"BabelQueueError",
|
|
50
58
|
"UnknownUrnError",
|
|
51
59
|
"DecryptError",
|
|
@@ -45,6 +45,7 @@ from .transport import (
|
|
|
45
45
|
Redeliverer,
|
|
46
46
|
Transport,
|
|
47
47
|
make_transport,
|
|
48
|
+
publish_keeping_route,
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
Handler = Callable[..., None]
|
|
@@ -278,6 +279,10 @@ class BabelQueue:
|
|
|
278
279
|
):
|
|
279
280
|
envelope = EnvelopeCodec.decode(received.body)
|
|
280
281
|
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
282
|
+
if received.route_urn and envelope:
|
|
283
|
+
# Transport-supplied routing override; body untouched. Only for a body that
|
|
284
|
+
# decodes: an undecodable one (decode() yields {}) stays on the poison path.
|
|
285
|
+
urn = received.route_urn
|
|
281
286
|
handler = self._handlers.get(urn) if urn else None
|
|
282
287
|
|
|
283
288
|
try:
|
|
@@ -381,9 +386,16 @@ class BabelQueue:
|
|
|
381
386
|
if isinstance(self.transport, Redeliverer):
|
|
382
387
|
self.transport.redeliver(received, body, delay)
|
|
383
388
|
return
|
|
384
|
-
self.
|
|
389
|
+
self._publish_for(received, received.queue, _without_forbidden_keys(body))
|
|
385
390
|
self._ack_settled(received)
|
|
386
391
|
|
|
392
|
+
def _publish_for(self, received: ReceivedMessage, queue: str, body: str) -> None:
|
|
393
|
+
"""Publish ``body`` to ``queue`` on behalf of ``received`` (a retry / release re-publish
|
|
394
|
+
or its dead-letter record). Keeps the routing URN the broker property carried on this
|
|
395
|
+
delivery when the transport can pin it (:class:`RoutedPublisher`), so a property/body
|
|
396
|
+
mismatch does not flip to the body URN on the next attempt or in the DLQ."""
|
|
397
|
+
publish_keeping_route(self.transport, queue, body, received.route_urn)
|
|
398
|
+
|
|
387
399
|
def _ack_settled(self, received: ReceivedMessage) -> None:
|
|
388
400
|
"""Acknowledge a message whose outcome is already final (handled, dead-lettered,
|
|
389
401
|
re-published, dropped or deleted by the unknown-URN strategy).
|
|
@@ -428,15 +440,22 @@ class BabelQueue:
|
|
|
428
440
|
if strategy == UnknownUrnStrategy.DELETE:
|
|
429
441
|
self._ack_settled(received)
|
|
430
442
|
return
|
|
431
|
-
|
|
443
|
+
# A transport's version-gated delivery, an undecodable body or a body naming no URN can
|
|
444
|
+
# never gain a handler, so releasing it unchanged would redeliver it forever. It takes the
|
|
445
|
+
# bounded FAIL path instead (retry up to max_attempts, then dead-letter or drop); only a
|
|
446
|
+
# genuinely unknown URN keeps the verbatim release.
|
|
447
|
+
gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
|
|
448
|
+
poison = gated or not urn
|
|
449
|
+
if strategy == UnknownUrnStrategy.RELEASE and not poison:
|
|
432
450
|
self._release(received, received.body, self.unknown_urn_release_delay)
|
|
433
451
|
return
|
|
434
452
|
if strategy == UnknownUrnStrategy.DEAD_LETTER:
|
|
435
453
|
self._dead_letter(received, dict(envelope), "unknown_urn", None)
|
|
436
454
|
return
|
|
437
|
-
# FAIL — surfaced through the retry/dead-letter path
|
|
455
|
+
# FAIL (and RELEASE of a poison message) — surfaced through the retry/dead-letter path
|
|
456
|
+
# (never kills the loop). A gated delivery names its reserved route (the rejected version).
|
|
438
457
|
raise UnknownUrnError(
|
|
439
|
-
f"No handler mapped for URN [{urn or '(empty)'}]."
|
|
458
|
+
f"No handler mapped for URN [{urn or (received.route_urn if gated else '') or '(empty)'}]."
|
|
440
459
|
)
|
|
441
460
|
|
|
442
461
|
def _retry_or_dead_letter(
|
|
@@ -474,10 +493,17 @@ class BabelQueue:
|
|
|
474
493
|
exception=(type(exc).__name__ if exc is not None else None),
|
|
475
494
|
)
|
|
476
495
|
target = self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
|
|
477
|
-
self.
|
|
496
|
+
self._publish_for(received, target, EnvelopeCodec.encode(annotated))
|
|
478
497
|
self._ack_settled(received)
|
|
479
498
|
|
|
480
499
|
|
|
500
|
+
#: Reserved ``ReceivedMessage.route_urn`` prefix a transport uses to hand over a delivery it
|
|
501
|
+
#: version-gated (broker-bindings §5.7 step 3, e.g. the pulsar transport's ``bq-schema-version``
|
|
502
|
+
#: check). Not a ``urn:babel:`` URN, so no handler owns it. Must equal
|
|
503
|
+
#: ``pulsar_transport.UNSUPPORTED_SCHEMA_ROUTE_PREFIX`` (locked by a test); never reaches the wire.
|
|
504
|
+
_UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
505
|
+
|
|
506
|
+
|
|
481
507
|
def _without_forbidden_keys(body: str) -> str:
|
|
482
508
|
"""``body`` unchanged, unless it carries a forbidden envelope key (message-envelope.md §10,
|
|
483
509
|
e.g. from a legacy producer) — then it is re-encoded without it (warned by the codec), so a
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Apache Pulsar transport. Requires the ``pulsar`` extra:
|
|
2
|
+
|
|
3
|
+
pip install "babelqueue[pulsar]"
|
|
4
|
+
|
|
5
|
+
Producing sends the canonical envelope as the message payload and projects the contract
|
|
6
|
+
envelope fields onto native Pulsar message properties (string->string): ``bq-job`` = URN,
|
|
7
|
+
``bq-trace-id`` = trace_id, ``bq-message-id`` = meta.id, plus ``bq-schema-version`` /
|
|
8
|
+
``bq-source-lang`` / ``bq-attempts`` — so a Java/.NET/... peer can route on ``bq-job``
|
|
9
|
+
without parsing the body. Consuming receives one message at a time
|
|
10
|
+
(``receive`` -> process -> ``acknowledge``). Per §5.2 / §5.7 the ``bq-job`` property is the
|
|
11
|
+
routing key (the body's URN is used only when ``bq-job`` is absent or blank) and the
|
|
12
|
+
``bq-attempts`` property is the authoritative attempt count (the body's ``attempts`` is used
|
|
13
|
+
only when the property is absent or not a plain non-negative ASCII-decimal integer that fits
|
|
14
|
+
a 32-bit int). The result is then raised to the broker's native ``redelivery_count``
|
|
15
|
+
(0-based): ``attempts = max(bq-attempts, redelivery_count)`` — no -1, because Pulsar's
|
|
16
|
+
redelivery count is 0-based (0 on first delivery). The routing URN is handed to the app through
|
|
17
|
+
``ReceivedMessage.route_urn`` — the body's ``job`` / ``urn`` is never rewritten, so retries and
|
|
18
|
+
the DLQ keep the producer's original body. When ``bq-job`` and the body name different URNs
|
|
19
|
+
the property wins and the optional ``on_routing_mismatch(property_urn, body_urn, envelope)``
|
|
20
|
+
hook is called (for an accepted envelope, after attempts are reconciled). A message left un-acked is redelivered (at-least-once).
|
|
21
|
+
|
|
22
|
+
Before any of that, a ``bq-schema-version`` property that is present but not the core's supported
|
|
23
|
+
``SCHEMA_VERSION`` (``"2"``, ``"x"``, ``"01"``, ...) is version-gated (§5.7 step 3): the body is
|
|
24
|
+
**not decoded** and the message is handed to the runtime under a reserved ``route_urn`` no
|
|
25
|
+
handler owns, so it is quarantined through the existing unknown-URN retry / dead-letter path (the
|
|
26
|
+
error names the rejected version, and a ``babelqueue.pulsar`` warning is logged with the version
|
|
27
|
+
and queue, also for a body that is not valid JSON). A missing or blank property (empty or only
|
|
28
|
+
ASCII whitespace: space, tab, LF, VT, FF, CR - nothing else counts as blank) decodes as usual.
|
|
29
|
+
The reserved route is an in-process marker only: it carries the delivery's original ``bq-job``,
|
|
30
|
+
and a retry / dead-letter re-publish writes that ``bq-job`` (or, when the delivery had none, the
|
|
31
|
+
body's job) and the original raw ``bq-schema-version`` back — never the sentinel — so a redrive
|
|
32
|
+
meets the same gate.
|
|
33
|
+
|
|
34
|
+
This implements §5 of the broker-bindings contract. The envelope is unchanged
|
|
35
|
+
(``schema_version`` stays 1); Apache Pulsar is purely additive.
|
|
36
|
+
|
|
37
|
+
URL form: ``pulsar://host:6650`` (or ``pulsar+ssl://``). The default subscription name is
|
|
38
|
+
``babelqueue`` over a ``Shared`` subscription. For a custom client/subscription, build the
|
|
39
|
+
transport directly and pass it via ``BabelQueue(transport=...)`` or
|
|
40
|
+
``PulsarTransport(client=...)``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
import re
|
|
47
|
+
from urllib.parse import quote, unquote
|
|
48
|
+
from typing import Any, Callable, Dict, Optional, Tuple
|
|
49
|
+
|
|
50
|
+
from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
|
|
51
|
+
from .transport import ReceivedMessage, Transport
|
|
52
|
+
|
|
53
|
+
#: ``bq-attempts`` is valid only as a run of ASCII digits (no sign, no whitespace) ...
|
|
54
|
+
_ATTEMPTS_PATTERN = re.compile(r"[0-9]+")
|
|
55
|
+
#: A blank ``bq-schema-version``: empty or only ASCII whitespace (space, \t, \n, \v, \f, \r) — the
|
|
56
|
+
#: same set in every SDK (GR-5). Deliberately not ``str.strip()``, which also strips U+001C-U+001F,
|
|
57
|
+
#: U+0085, U+00A0, U+2028, U+3000 ...
|
|
58
|
+
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
59
|
+
#: ... that fits a signed 32-bit int, matching the Java and .NET consumers (GR-5).
|
|
60
|
+
_MAX_ATTEMPTS = 2**31 - 1
|
|
61
|
+
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
62
|
+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime reports it as an
|
|
63
|
+
#: unknown URN, which names the rejected ``bq-schema-version``.
|
|
64
|
+
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
65
|
+
#: Separates the rejected version from the delivery's original ``bq-job`` in a gate route.
|
|
66
|
+
_GATE_JOB_PARAM = "?bq-job="
|
|
67
|
+
|
|
68
|
+
_logger = logging.getLogger("babelqueue.pulsar")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _gate_route(version: str, properties: Dict[str, Any]) -> str:
|
|
72
|
+
"""The reserved route of a version-gated delivery: the prefix, the rejected version and, when
|
|
73
|
+
the delivery carried one, its original ``bq-job`` (both percent-escaped). The runtime hands
|
|
74
|
+
it back through :meth:`PulsarTransport.publish_routed` on every retry / dead-letter write."""
|
|
75
|
+
route = UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(version, safe="")
|
|
76
|
+
job = properties.get("bq-job")
|
|
77
|
+
if job is not None:
|
|
78
|
+
route += _GATE_JOB_PARAM + quote(str(job), safe="")
|
|
79
|
+
return route
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_gate_route(route: str) -> Optional[Tuple[str, Optional[str]]]:
|
|
83
|
+
"""Inverse of :func:`_gate_route`: ``(version, bq_job_or_None)``, or ``None`` for any other
|
|
84
|
+
route."""
|
|
85
|
+
if not route.startswith(UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
|
|
86
|
+
return None
|
|
87
|
+
rest = route[len(UNSUPPORTED_SCHEMA_ROUTE_PREFIX):]
|
|
88
|
+
raw_version, sep, raw_job = rest.partition(_GATE_JOB_PARAM)
|
|
89
|
+
return unquote(raw_version), (unquote(raw_job) if sep else None)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PulsarTransport(Transport):
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
url: str = "pulsar://localhost:6650",
|
|
96
|
+
*,
|
|
97
|
+
client: Any = None,
|
|
98
|
+
subscription: str = "babelqueue",
|
|
99
|
+
consumer_type: Any = None,
|
|
100
|
+
topic_prefix: str = "",
|
|
101
|
+
receive_timeout_millis: int = 1000,
|
|
102
|
+
on_routing_mismatch: Optional[Callable[[str, str, Dict[str, Any]], None]] = None,
|
|
103
|
+
**subscribe_options: Any,
|
|
104
|
+
) -> None:
|
|
105
|
+
self._url = url or "pulsar://localhost:6650"
|
|
106
|
+
self._subscription = subscription
|
|
107
|
+
self._consumer_type = consumer_type
|
|
108
|
+
self._topic_prefix = topic_prefix
|
|
109
|
+
self._receive_timeout_millis = receive_timeout_millis
|
|
110
|
+
self._subscribe_options = subscribe_options
|
|
111
|
+
self._on_routing_mismatch = on_routing_mismatch
|
|
112
|
+
self._producers: Dict[str, Any] = {}
|
|
113
|
+
self._consumers: Dict[str, Any] = {}
|
|
114
|
+
|
|
115
|
+
if client is not None:
|
|
116
|
+
self._client = client
|
|
117
|
+
return
|
|
118
|
+
self._client = self._build_client() # pragma: no cover - needs Pulsar / network
|
|
119
|
+
|
|
120
|
+
def _build_client(self) -> Any: # pragma: no cover - needs Pulsar / network
|
|
121
|
+
try:
|
|
122
|
+
import pulsar
|
|
123
|
+
except ImportError as exc:
|
|
124
|
+
raise ImportError(
|
|
125
|
+
"PulsarTransport requires the 'pulsar-client' package. Install with "
|
|
126
|
+
'pip install "babelqueue[pulsar]".'
|
|
127
|
+
) from exc
|
|
128
|
+
return pulsar.Client(self._url)
|
|
129
|
+
|
|
130
|
+
# -- helpers ------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _topic(self, queue: str) -> str:
|
|
133
|
+
return f"{self._topic_prefix}{queue}" if self._topic_prefix else queue
|
|
134
|
+
|
|
135
|
+
def _producer(self, queue: str) -> Any:
|
|
136
|
+
producer = self._producers.get(queue)
|
|
137
|
+
if producer is None:
|
|
138
|
+
producer = self._client.create_producer(self._topic(queue))
|
|
139
|
+
self._producers[queue] = producer
|
|
140
|
+
return producer
|
|
141
|
+
|
|
142
|
+
def _consumer(self, queue: str) -> Any:
|
|
143
|
+
consumer = self._consumers.get(queue)
|
|
144
|
+
if consumer is None:
|
|
145
|
+
consumer = self._client.subscribe(
|
|
146
|
+
self._topic(queue), self._subscription, **self._subscribe_kwargs()
|
|
147
|
+
)
|
|
148
|
+
self._consumers[queue] = consumer
|
|
149
|
+
return consumer
|
|
150
|
+
|
|
151
|
+
def _subscribe_kwargs(self) -> Dict[str, Any]:
|
|
152
|
+
kwargs = dict(self._subscribe_options)
|
|
153
|
+
consumer_type = self._consumer_type
|
|
154
|
+
if consumer_type is None:
|
|
155
|
+
consumer_type = self._default_shared_type()
|
|
156
|
+
if consumer_type is not None:
|
|
157
|
+
kwargs.setdefault("consumer_type", consumer_type)
|
|
158
|
+
return kwargs
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _default_shared_type() -> Any:
|
|
162
|
+
try:
|
|
163
|
+
import pulsar
|
|
164
|
+
except ImportError: # pragma: no cover - exercised only with pulsar absent
|
|
165
|
+
return None
|
|
166
|
+
return pulsar.ConsumerType.Shared # pragma: no cover - needs Pulsar
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _projection(body: str, route_urn: str = "") -> Dict[str, str]:
|
|
170
|
+
"""Native Pulsar message properties (string->string) — a redundant, routable view of
|
|
171
|
+
the body: bq-job/bq-trace-id/bq-message-id + bq-schema-version/lang/attempts. §5.2.
|
|
172
|
+
|
|
173
|
+
A non-empty ``route_urn`` pins ``bq-job`` (a retry / release re-publish keeps the original
|
|
174
|
+
delivery's routing URN); blank derives it from the body.
|
|
175
|
+
|
|
176
|
+
A ``route_urn`` that is a version-gate route is never written: the original delivery's
|
|
177
|
+
``bq-job`` (if it had one) and raw ``bq-schema-version`` are restored instead, so the
|
|
178
|
+
republished message is gated the same way when it is consumed again."""
|
|
179
|
+
gate = _parse_gate_route(route_urn)
|
|
180
|
+
env = parse_envelope(body)
|
|
181
|
+
if gate is not None:
|
|
182
|
+
props = PulsarTransport._projection(body) if env else {}
|
|
183
|
+
props["bq-schema-version"] = gate[0]
|
|
184
|
+
if gate[1] is not None:
|
|
185
|
+
props["bq-job"] = gate[1]
|
|
186
|
+
return props
|
|
187
|
+
if not env:
|
|
188
|
+
return {}
|
|
189
|
+
meta = env.get("meta") or {}
|
|
190
|
+
|
|
191
|
+
props: Dict[str, str] = {}
|
|
192
|
+
if env.get("job"):
|
|
193
|
+
props["bq-job"] = str(env["job"])
|
|
194
|
+
if env.get("trace_id"):
|
|
195
|
+
props["bq-trace-id"] = str(env["trace_id"])
|
|
196
|
+
if meta.get("id"):
|
|
197
|
+
props["bq-message-id"] = str(meta["id"])
|
|
198
|
+
if meta.get("schema_version") is not None:
|
|
199
|
+
props["bq-schema-version"] = str(meta["schema_version"])
|
|
200
|
+
if meta.get("lang"):
|
|
201
|
+
props["bq-source-lang"] = str(meta["lang"])
|
|
202
|
+
props["bq-attempts"] = str(int(env.get("attempts", 0) or 0))
|
|
203
|
+
if route_urn:
|
|
204
|
+
props["bq-job"] = route_urn
|
|
205
|
+
return props
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _unsupported_schema_version(properties: Dict[str, Any]) -> Optional[str]:
|
|
209
|
+
"""The §5.7 step-3 gate on ``bq-schema-version``: the offending raw value, or ``None``.
|
|
210
|
+
|
|
211
|
+
A missing or blank property is not gated (the usual decode path runs); blank means the
|
|
212
|
+
empty string or only ASCII whitespace (space, ``\\t``, ``\\n``, ``\\v``, ``\\f``, ``\\r``) — the
|
|
213
|
+
same set in every SDK (GR-5), not ``str.strip()``, which also strips U+001C-U+001F,
|
|
214
|
+
U+0085, U+00A0, U+2028, U+3000 ... A present property is accepted only when it is exactly
|
|
215
|
+
the core's supported ``SCHEMA_VERSION`` as a plain decimal string, so ``"2"``, ``"x"``,
|
|
216
|
+
``"01"`` and ``" 1"`` are all rejected."""
|
|
217
|
+
raw = properties.get("bq-schema-version")
|
|
218
|
+
if raw is None or (isinstance(raw, str) and _BLANK_ASCII_PATTERN.fullmatch(raw) is not None):
|
|
219
|
+
return None
|
|
220
|
+
if isinstance(raw, str) and raw == str(SCHEMA_VERSION):
|
|
221
|
+
return None
|
|
222
|
+
return str(raw)
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _parse_attempts(raw: Any) -> Optional[int]:
|
|
226
|
+
"""The ``bq-attempts`` value as an int, or ``None`` when absent or invalid (§5.5)."""
|
|
227
|
+
if not isinstance(raw, str) or _ATTEMPTS_PATTERN.fullmatch(raw) is None:
|
|
228
|
+
return None
|
|
229
|
+
value = int(raw)
|
|
230
|
+
return value if value <= _MAX_ATTEMPTS else None
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _reconcile(
|
|
234
|
+
body: str,
|
|
235
|
+
redelivery_count: Any,
|
|
236
|
+
properties: Optional[Dict[str, str]] = None,
|
|
237
|
+
on_routing_mismatch: Optional[Callable[[str, str, Dict[str, Any]], None]] = None,
|
|
238
|
+
) -> Tuple[str, str]:
|
|
239
|
+
"""Apply §5.2 / §5.7 to a received body; returns ``(body, route_urn)``.
|
|
240
|
+
|
|
241
|
+
* Routing: a non-blank ``bq-job`` property is the URN the app routes on. It is returned
|
|
242
|
+
as ``route_urn`` (set on :attr:`ReceivedMessage.route_urn`); the body's ``job`` /
|
|
243
|
+
``urn`` is never rewritten, so the handler, retries and the dead-letter record keep the
|
|
244
|
+
producer's original body. A body with no URN gets no ``route_urn`` — it stays
|
|
245
|
+
non-conformant and is rejected whatever ``bq-job`` says.
|
|
246
|
+
* Attempts: a valid ``bq-attempts`` property replaces the body's value (the body's is
|
|
247
|
+
kept when it is absent or invalid), then the result is raised to
|
|
248
|
+
``redelivery_count``. Pulsar's count is 0-based (0 on first delivery), so it maps
|
|
249
|
+
directly with no -1; the max never lowers a runtime-incremented counter (the runtime
|
|
250
|
+
retries by republishing with attempts+1, which resets the redelivery count to 0). The
|
|
251
|
+
value is written into the body only when the body already holds an integer
|
|
252
|
+
``attempts`` and the value really changes, so a missing / non-integer ``attempts`` is
|
|
253
|
+
still rejected by ``EnvelopeCodec.accepts``.
|
|
254
|
+
* ``on_routing_mismatch(property_urn, body_urn, envelope)`` is called when the property
|
|
255
|
+
and the body name different URNs — only for an envelope the consumer accepts and after
|
|
256
|
+
attempts are reconciled (as in the Java and .NET consumers), so it sees what the
|
|
257
|
+
handler will get. The hook is optional, separate from any error handling, and any
|
|
258
|
+
exception it raises is swallowed — it never affects the message flow.
|
|
259
|
+
"""
|
|
260
|
+
properties = properties or {}
|
|
261
|
+
env = parse_envelope(body)
|
|
262
|
+
if not env:
|
|
263
|
+
return body, ""
|
|
264
|
+
|
|
265
|
+
routed = str(properties.get("bq-job") or "").strip()
|
|
266
|
+
body_urn = EnvelopeCodec.urn(env)
|
|
267
|
+
route_urn = routed if routed and body_urn else ""
|
|
268
|
+
|
|
269
|
+
current = env.get("attempts")
|
|
270
|
+
has_int_attempts = isinstance(current, int) and not isinstance(current, bool)
|
|
271
|
+
attempts = current if has_int_attempts else 0
|
|
272
|
+
from_property = PulsarTransport._parse_attempts(properties.get("bq-attempts"))
|
|
273
|
+
if from_property is not None:
|
|
274
|
+
attempts = from_property
|
|
275
|
+
try:
|
|
276
|
+
rc = int(redelivery_count)
|
|
277
|
+
except (TypeError, ValueError):
|
|
278
|
+
rc = 0
|
|
279
|
+
if rc > attempts:
|
|
280
|
+
attempts = rc
|
|
281
|
+
|
|
282
|
+
out = body
|
|
283
|
+
if has_int_attempts and current != attempts:
|
|
284
|
+
env["attempts"] = attempts
|
|
285
|
+
out = EnvelopeCodec.encode(env)
|
|
286
|
+
|
|
287
|
+
if route_urn and route_urn != body_urn and on_routing_mismatch is not None:
|
|
288
|
+
if EnvelopeCodec.accepts(env):
|
|
289
|
+
try:
|
|
290
|
+
on_routing_mismatch(route_urn, body_urn, dict(env))
|
|
291
|
+
except Exception: # noqa: BLE001 - a diagnostic hook must never break consumption
|
|
292
|
+
pass
|
|
293
|
+
return out, route_urn
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _redelivery_count(message: Any) -> int:
|
|
297
|
+
getter = getattr(message, "redelivery_count", None)
|
|
298
|
+
value = getter() if callable(getter) else getter
|
|
299
|
+
if value is None:
|
|
300
|
+
return 0
|
|
301
|
+
try:
|
|
302
|
+
return int(value)
|
|
303
|
+
except (TypeError, ValueError):
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _properties(message: Any) -> Dict[str, str]:
|
|
308
|
+
getter = getattr(message, "properties", None)
|
|
309
|
+
value = getter() if callable(getter) else getter
|
|
310
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _payload(message: Any) -> str:
|
|
314
|
+
getter = getattr(message, "data", None)
|
|
315
|
+
data = getter() if callable(getter) else getter
|
|
316
|
+
if isinstance(data, bytes):
|
|
317
|
+
return data.decode("utf-8")
|
|
318
|
+
return str(data) if data is not None else ""
|
|
319
|
+
|
|
320
|
+
# -- Transport ----------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def publish(self, queue: str, body: str) -> None:
|
|
323
|
+
self.publish_routed(queue, body, "")
|
|
324
|
+
|
|
325
|
+
def publish_routed(self, queue: str, body: str, route_urn: str) -> None:
|
|
326
|
+
""":meth:`publish` with ``bq-job`` pinned to ``route_urn`` (:class:`RoutedPublisher`);
|
|
327
|
+
a blank ``route_urn`` derives it from the body."""
|
|
328
|
+
self._producer(queue).send(
|
|
329
|
+
body.encode("utf-8"), properties=self._projection(body, route_urn)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
333
|
+
wait = self._receive_timeout_millis
|
|
334
|
+
if timeout and timeout > 0:
|
|
335
|
+
wait = int(timeout * 1000)
|
|
336
|
+
consumer = self._consumer(queue)
|
|
337
|
+
try:
|
|
338
|
+
message = consumer.receive(timeout_millis=wait)
|
|
339
|
+
except Exception as exc: # noqa: BLE001 - Pulsar raises pulsar.Timeout on no message
|
|
340
|
+
if type(exc).__name__ == "Timeout":
|
|
341
|
+
return None
|
|
342
|
+
raise
|
|
343
|
+
if message is None: # pragma: no cover - defensive (real client raises instead)
|
|
344
|
+
return None
|
|
345
|
+
properties = self._properties(message)
|
|
346
|
+
rejected = self._unsupported_schema_version(properties)
|
|
347
|
+
if rejected is not None:
|
|
348
|
+
# Version gate (§5.7 step 3): quarantine before decoding. The body is handed over
|
|
349
|
+
# untouched; the reserved route keeps the runtime from dispatching it to a handler.
|
|
350
|
+
_logger.warning(
|
|
351
|
+
"Pulsar message on %r rejected by the bq-schema-version gate: unsupported "
|
|
352
|
+
"version %r (supported: %s); quarantined without decoding the body",
|
|
353
|
+
queue, rejected, SCHEMA_VERSION,
|
|
354
|
+
)
|
|
355
|
+
return ReceivedMessage(
|
|
356
|
+
body=self._payload(message),
|
|
357
|
+
queue=queue,
|
|
358
|
+
handle=message,
|
|
359
|
+
route_urn=_gate_route(rejected, properties),
|
|
360
|
+
)
|
|
361
|
+
body, route_urn = self._reconcile(
|
|
362
|
+
self._payload(message),
|
|
363
|
+
self._redelivery_count(message),
|
|
364
|
+
properties,
|
|
365
|
+
self._on_routing_mismatch,
|
|
366
|
+
)
|
|
367
|
+
return ReceivedMessage(body=body, queue=queue, handle=message, route_urn=route_urn)
|
|
368
|
+
|
|
369
|
+
def ack(self, message: ReceivedMessage) -> None:
|
|
370
|
+
if message.handle is None:
|
|
371
|
+
return
|
|
372
|
+
self._consumer(message.queue).acknowledge(message.handle)
|
|
373
|
+
|
|
374
|
+
def close(self) -> None: # pragma: no cover - resource cleanup
|
|
375
|
+
for resource in (*self._producers.values(), *self._consumers.values()):
|
|
376
|
+
try:
|
|
377
|
+
resource.close()
|
|
378
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
379
|
+
pass
|
|
380
|
+
try:
|
|
381
|
+
self._client.close()
|
|
382
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
383
|
+
pass
|
|
@@ -36,7 +36,7 @@ from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
|
36
36
|
|
|
37
37
|
from .codec import EnvelopeCodec
|
|
38
38
|
from .replay import HEADER_REPLAY_BYPASS
|
|
39
|
-
from .transport import HeaderPublisher, ReceivedMessage, Transport
|
|
39
|
+
from .transport import HeaderPublisher, ReceivedMessage, Transport, publish_keeping_route
|
|
40
40
|
|
|
41
41
|
Envelope = Mapping[str, Any]
|
|
42
42
|
Select = Callable[[Envelope], bool]
|
|
@@ -88,7 +88,7 @@ def redrive(
|
|
|
88
88
|
result = RedriveResult()
|
|
89
89
|
for message, envelope in batch:
|
|
90
90
|
if envelope is None:
|
|
91
|
-
transport
|
|
91
|
+
publish_keeping_route(transport, dlq, message.body, message.route_urn) # restore the poison body; never drop it
|
|
92
92
|
transport.ack(message)
|
|
93
93
|
result.skipped += 1
|
|
94
94
|
result.items.append(RedriveItem("", "", "", "", dlq, "", False))
|
|
@@ -109,7 +109,7 @@ def redrive(
|
|
|
109
109
|
)
|
|
110
110
|
|
|
111
111
|
if select is not None and not select(envelope):
|
|
112
|
-
transport
|
|
112
|
+
publish_keeping_route(transport, dlq, message.body, message.route_urn) # not selected: restore unchanged
|
|
113
113
|
transport.ack(message)
|
|
114
114
|
result.skipped += 1
|
|
115
115
|
result.items.append(item)
|
|
@@ -119,7 +119,7 @@ def redrive(
|
|
|
119
119
|
item.to = target
|
|
120
120
|
|
|
121
121
|
if dry_run:
|
|
122
|
-
transport
|
|
122
|
+
publish_keeping_route(transport, dlq, message.body, message.route_urn) # report the plan; restore unchanged
|
|
123
123
|
transport.ack(message)
|
|
124
124
|
result.skipped += 1
|
|
125
125
|
result.items.append(item)
|
|
@@ -130,10 +130,10 @@ def redrive(
|
|
|
130
130
|
reset["attempts"] = 0
|
|
131
131
|
try:
|
|
132
132
|
item.bypassed = _publish_redriven(
|
|
133
|
-
transport, target, EnvelopeCodec.encode(reset), bypass
|
|
133
|
+
transport, target, EnvelopeCodec.encode(reset), bypass, message.route_urn
|
|
134
134
|
)
|
|
135
135
|
except Exception:
|
|
136
|
-
transport
|
|
136
|
+
publish_keeping_route(transport, dlq, message.body, message.route_urn) # restore on a publish failure, then surface
|
|
137
137
|
transport.ack(message)
|
|
138
138
|
raise
|
|
139
139
|
transport.ack(message)
|
|
@@ -156,14 +156,18 @@ def _decoded(body: str) -> Optional[Dict[str, Any]]:
|
|
|
156
156
|
return envelope
|
|
157
157
|
|
|
158
158
|
|
|
159
|
-
def _publish_redriven(
|
|
159
|
+
def _publish_redriven(
|
|
160
|
+
transport: Transport, queue: str, body: str, bypass: bool, route_urn: str = ""
|
|
161
|
+
) -> bool:
|
|
160
162
|
"""Re-publish a reset message to ``queue``. When ``bypass`` is set and the transport is a
|
|
161
163
|
:class:`~babelqueue.transport.HeaderPublisher`, stamp the ``bq-replay-bypass`` header and
|
|
162
|
-
return True; otherwise publish plainly and return False.
|
|
164
|
+
return True; otherwise publish plainly and return False. Either way a non-empty ``route_urn``
|
|
165
|
+
(the dead-lettered message's routing property) is carried back through
|
|
166
|
+
:class:`~babelqueue.transport.RoutedPublisher` when the transport implements it."""
|
|
163
167
|
if bypass and isinstance(transport, HeaderPublisher):
|
|
164
168
|
transport.publish_with_headers(queue, body, {HEADER_REPLAY_BYPASS: "1"})
|
|
165
169
|
return True
|
|
166
|
-
transport
|
|
170
|
+
publish_keeping_route(transport, queue, body, route_urn)
|
|
167
171
|
return False
|
|
168
172
|
|
|
169
173
|
|