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.
Files changed (94) hide show
  1. {babelqueue-1.14.0 → babelqueue-1.15.0}/CHANGELOG.md +59 -0
  2. {babelqueue-1.14.0 → babelqueue-1.15.0}/PKG-INFO +2 -2
  3. {babelqueue-1.14.0 → babelqueue-1.15.0}/pyproject.toml +2 -2
  4. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/__init__.py +10 -2
  5. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/app.py +31 -5
  6. babelqueue-1.15.0/src/babelqueue/pulsar_transport.py +383 -0
  7. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/redrive.py +13 -9
  8. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/transport.py +30 -0
  9. babelqueue-1.15.0/tests/test_app.py +303 -0
  10. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_pulsar_conformance.py +1 -1
  11. babelqueue-1.15.0/tests/test_pulsar_transport.py +648 -0
  12. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_redrive.py +38 -0
  13. babelqueue-1.14.0/src/babelqueue/pulsar_transport.py +0 -202
  14. babelqueue-1.14.0/tests/test_app.py +0 -125
  15. babelqueue-1.14.0/tests/test_pulsar_transport.py +0 -240
  16. {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/FUNDING.yml +0 -0
  17. {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/dependabot.yml +0 -0
  18. {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/workflows/ci.yml +0 -0
  19. {babelqueue-1.14.0 → babelqueue-1.15.0}/.github/workflows/release.yml +0 -0
  20. {babelqueue-1.14.0 → babelqueue-1.15.0}/.gitignore +0 -0
  21. {babelqueue-1.14.0 → babelqueue-1.15.0}/LICENSE +0 -0
  22. {babelqueue-1.14.0 → babelqueue-1.15.0}/README.md +0 -0
  23. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/artemis_transport.py +0 -0
  24. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/asb_transport.py +0 -0
  25. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/celery.py +0 -0
  26. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/codec.py +0 -0
  27. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/contracts.py +0 -0
  28. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/dead_letter.py +0 -0
  29. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/__init__.py +0 -0
  30. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/apps.py +0 -0
  31. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/management/__init__.py +0 -0
  32. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  33. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  34. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/exceptions.py +0 -0
  35. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/gdpr.py +0 -0
  36. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/headers.py +0 -0
  37. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/idempotency.py +0 -0
  38. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/kafka_transport.py +0 -0
  39. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/otel.py +0 -0
  40. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/outbox.py +0 -0
  41. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/pika_transport.py +0 -0
  42. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/py.typed +0 -0
  43. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/redis_transport.py +0 -0
  44. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/replay.py +0 -0
  45. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/routing.py +0 -0
  46. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/schema.py +0 -0
  47. {babelqueue-1.14.0 → babelqueue-1.15.0}/src/babelqueue/sqs_transport.py +0 -0
  48. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/CONFORMANCE_VERSION +0 -0
  49. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  50. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  51. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  52. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  53. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  54. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  55. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  56. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  57. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  58. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  59. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/order-created.json +0 -0
  60. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  61. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  62. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  63. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  64. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  65. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/manifest.json +0 -0
  66. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  67. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/fixtures/dead-lettered.json +0 -0
  68. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/fixtures/order-created.json +0 -0
  69. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_artemis_conformance.py +0 -0
  70. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_artemis_transport.py +0 -0
  71. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_asb_conformance.py +0 -0
  72. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_asb_transport.py +0 -0
  73. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_behaviour_conformance.py +0 -0
  74. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_celery.py +0 -0
  75. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_codec.py +0 -0
  76. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_conformance.py +0 -0
  77. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_dead_letter.py +0 -0
  78. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_django.py +0 -0
  79. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_gdpr.py +0 -0
  80. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_headers.py +0 -0
  81. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_idempotency.py +0 -0
  82. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_kafka_conformance.py +0 -0
  83. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_kafka_transport.py +0 -0
  84. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_otel.py +0 -0
  85. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_outbox.py +0 -0
  86. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_overhead.py +0 -0
  87. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_pika_transport.py +0 -0
  88. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_redis_transport.py +0 -0
  89. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_replay.py +0 -0
  90. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_schema.py +0 -0
  91. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_schema_conformance.py +0 -0
  92. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_shutdown.py +0 -0
  93. {babelqueue-1.14.0 → babelqueue-1.15.0}/tests/test_sqs_conformance.py +0 -0
  94. {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.14.0
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.tr>
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.14.0"
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.tr" }]
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 HeaderPublisher, InMemoryTransport, ReceivedMessage, Redeliverer, Transport
32
+ from .transport import (
33
+ HeaderPublisher,
34
+ InMemoryTransport,
35
+ ReceivedMessage,
36
+ Redeliverer,
37
+ RoutedPublisher,
38
+ Transport,
39
+ )
33
40
 
34
- __version__ = "1.14.0"
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.transport.publish(received.queue, _without_forbidden_keys(body))
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
- if strategy == UnknownUrnStrategy.RELEASE:
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 (never kills the loop).
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.transport.publish(target, EnvelopeCodec.encode(annotated))
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.publish(dlq, message.body) # restore the poison body; never drop it
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.publish(dlq, message.body) # not selected: restore unchanged
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.publish(dlq, message.body) # report the plan; restore unchanged
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.publish(dlq, message.body) # restore on a publish failure, then surface
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(transport: Transport, queue: str, body: str, bypass: bool) -> bool:
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.publish(queue, body)
170
+ publish_keeping_route(transport, queue, body, route_urn)
167
171
  return False
168
172
 
169
173