Skip to content

Commit 7c9ebd3

Browse files
committed
fix: route Pulsar messages on bq-job and keep the route across retry, DLQ and redrive
The Pulsar consumer now follows broker-bindings §5.2/§5.7: it routes on the bq-job property, falling back to the body URN only when the property is absent or blank, and takes attempts from the authoritative bq-attempts property (ASCII digits only), falling back to the body and still raised to the redelivery count. The handler keeps the decoded body envelope. An optional routing-mismatch hook reports a bq-job that disagrees with the body; a throwing hook is swallowed and settlement is unchanged. To route without rewriting the body, received messages gain an optional route_urn that the runtime dispatches on, and an optional routed publisher lets the retry, release, dead-letter and redrive paths republish with the original bq-job. An undecodable body never takes the route. Transports that leave route_urn unset behave as before.
1 parent 3d9e342 commit 7c9ebd3

10 files changed

Lines changed: 629 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,40 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
### Added
13+
- **`ReceivedMessage.route_urn` routing seam** (MINOR). An optional field a transport can set
14+
when its broker carries the URN out of band. When non-empty `BabelQueue.dispatch()` looks the
15+
handler up by it instead of the decoded body's URN; empty (the default for every existing
16+
transport) keeps routing on the body. The body is never rewritten, so the handler, the retry
17+
re-publish and the dead-letter record carry the producer's original body (including a `urn`
18+
alias). No wire change.
19+
- **`RoutedPublisher` optional transport capability** (MINOR). `publish_routed(queue, body,
20+
route_urn)`. When a delivery has a `route_urn` and the transport implements it, the retry, release and dead-letter
21+
writes, and `redrive`, use it so the broker's routing property keeps the original
22+
value across attempts (no flip to the body URN on the second delivery). `PulsarTransport`
23+
implements it (`bq-job` pinned; blank derives from the body). Others keep plain `publish`.
24+
No wire change.
25+
26+
### Changed
27+
- `BabelQueue.dispatch()` applies `route_urn` only to a body that decodes; an undecodable body
28+
stays on the poison / unknown-URN path instead of being routed to a handler.
29+
30+
### Fixed
31+
- **Pulsar consumer now follows broker-bindings §5.2 / §5.7.** `PulsarTransport.pop()` routes on
32+
the `bq-job` message property (set on `ReceivedMessage.route_urn`; the body's URN is used only
33+
when the property is absent or blank) and takes `attempts` from the authoritative
34+
`bq-attempts` property (the body's value is used only when the property is absent or not a
35+
plain ASCII-decimal integer that fits a 32-bit int), then applies `max(.., redelivery_count)`.
36+
Previously only the body was read. The body's `job` / `urn` is **not** rewritten, so a
37+
property/body mismatch no longer leaks into retries or the DLQ; `attempts` is written into the
38+
body only when the body already holds an integer `attempts` and the value changes (a missing
39+
or non-integer `attempts` is still rejected by `EnvelopeCodec.accepts`). When the property and
40+
the body name different URNs the property wins and the new optional
41+
`on_routing_mismatch(property_urn, body_urn, envelope)` constructor hook is called for an
42+
accepted envelope, after attempts are reconciled (its exceptions are swallowed; it is separate
43+
from error handling). A body without a URN is still rejected. No wire change
44+
(`schema_version: 1`).
45+
1246
## [1.14.0] - 2026-10-03
1347

1448
### Added

‎src/babelqueue/__init__.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@
2929
from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
3030
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
3131
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+
)
3340

3441
__version__ = "1.14.0"
3542

@@ -46,6 +53,7 @@
4653
"ReceivedMessage",
4754
"HeaderPublisher",
4855
"Redeliverer",
56+
"RoutedPublisher",
4957
"BabelQueueError",
5058
"UnknownUrnError",
5159
"DecryptError",

‎src/babelqueue/app.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def on_order_created(data, meta):
4545
Redeliverer,
4646
Transport,
4747
make_transport,
48+
publish_keeping_route,
4849
)
4950

5051
Handler = Callable[..., None]
@@ -278,6 +279,10 @@ def dispatch(self, received: ReceivedMessage) -> None:
278279
):
279280
envelope = EnvelopeCodec.decode(received.body)
280281
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
281286
handler = self._handlers.get(urn) if urn else None
282287

283288
try:
@@ -381,9 +386,16 @@ def _release(self, received: ReceivedMessage, body: str, delay: float) -> None:
381386
if isinstance(self.transport, Redeliverer):
382387
self.transport.redeliver(received, body, delay)
383388
return
384-
self.transport.publish(received.queue, _without_forbidden_keys(body))
389+
self._publish_for(received, received.queue, _without_forbidden_keys(body))
385390
self._ack_settled(received)
386391

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+
387399
def _ack_settled(self, received: ReceivedMessage) -> None:
388400
"""Acknowledge a message whose outcome is already final (handled, dead-lettered,
389401
re-published, dropped or deleted by the unknown-URN strategy).
@@ -474,7 +486,7 @@ def _dead_letter(
474486
exception=(type(exc).__name__ if exc is not None else None),
475487
)
476488
target = self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
477-
self.transport.publish(target, EnvelopeCodec.encode(annotated))
489+
self._publish_for(received, target, EnvelopeCodec.encode(annotated))
478490
self._ack_settled(received)
479491

480492

‎src/babelqueue/pulsar_transport.py‎

Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
``bq-trace-id`` = trace_id, ``bq-message-id`` = meta.id, plus ``bq-schema-version`` /
88
``bq-source-lang`` / ``bq-attempts`` — so a Java/.NET/... peer can route on ``bq-job``
99
without parsing the body. Consuming receives one message at a time
10-
(``receive`` -> process -> ``acknowledge``); the authoritative attempt count is the
11-
envelope's ``bq-attempts`` (the body), cross-checked against the broker's native
12-
``redelivery_count`` (0-based) and reconciled onto the envelope as
13-
``attempts = max(body, redelivery_count)`` — no -1, because Pulsar's redelivery count is
14-
0-based (0 on first delivery). A message left un-acked is redelivered (at-least-once).
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).
1521
1622
This implements §5 of the broker-bindings contract. The envelope is unchanged
1723
(``schema_version`` stays 1); Apache Pulsar is purely additive.
@@ -24,11 +30,17 @@
2430

2531
from __future__ import annotations
2632

27-
from typing import Any, Dict, Optional
33+
import re
34+
from typing import Any, Callable, Dict, Optional, Tuple
2835

2936
from .codec import EnvelopeCodec, parse_envelope
3037
from .transport import ReceivedMessage, Transport
3138

39+
#: ``bq-attempts`` is valid only as a run of ASCII digits (no sign, no whitespace) ...
40+
_ATTEMPTS_PATTERN = re.compile(r"[0-9]+")
41+
#: ... that fits a signed 32-bit int, matching the Java and .NET consumers (GR-5).
42+
_MAX_ATTEMPTS = 2**31 - 1
43+
3244

3345
class PulsarTransport(Transport):
3446
def __init__(
@@ -40,6 +52,7 @@ def __init__(
4052
consumer_type: Any = None,
4153
topic_prefix: str = "",
4254
receive_timeout_millis: int = 1000,
55+
on_routing_mismatch: Optional[Callable[[str, str, Dict[str, Any]], None]] = None,
4356
**subscribe_options: Any,
4457
) -> None:
4558
self._url = url or "pulsar://localhost:6650"
@@ -48,6 +61,7 @@ def __init__(
4861
self._topic_prefix = topic_prefix
4962
self._receive_timeout_millis = receive_timeout_millis
5063
self._subscribe_options = subscribe_options
64+
self._on_routing_mismatch = on_routing_mismatch
5165
self._producers: Dict[str, Any] = {}
5266
self._consumers: Dict[str, Any] = {}
5367

@@ -105,9 +119,12 @@ def _default_shared_type() -> Any:
105119
return pulsar.ConsumerType.Shared # pragma: no cover - needs Pulsar
106120

107121
@staticmethod
108-
def _projection(body: str) -> Dict[str, str]:
122+
def _projection(body: str, route_urn: str = "") -> Dict[str, str]:
109123
"""Native Pulsar message properties (string->string) — a redundant, routable view of
110-
the body: bq-job/bq-trace-id/bq-message-id + bq-schema-version/lang/attempts. §5.2."""
124+
the body: bq-job/bq-trace-id/bq-message-id + bq-schema-version/lang/attempts. §5.2.
125+
126+
A non-empty ``route_urn`` pins ``bq-job`` (a retry / release re-publish keeps the original
127+
delivery's routing URN); blank derives it from the body."""
111128
env = parse_envelope(body)
112129
if not env:
113130
return {}
@@ -125,25 +142,80 @@ def _projection(body: str) -> Dict[str, str]:
125142
if meta.get("lang"):
126143
props["bq-source-lang"] = str(meta["lang"])
127144
props["bq-attempts"] = str(int(env.get("attempts", 0) or 0))
145+
if route_urn:
146+
props["bq-job"] = route_urn
128147
return props
129148

130149
@staticmethod
131-
def _reconcile(body: str, redelivery_count: Any) -> str:
132-
"""Set attempts to max(current, redelivery_count). Pulsar's redelivery count is
133-
0-based (0 on first delivery), so it maps directly to attempts with no -1. The
134-
runtime retries by republishing with attempts+1 in the body (redelivery count back to
135-
0), so a republished message must not have its higher body count lowered."""
150+
def _parse_attempts(raw: Any) -> Optional[int]:
151+
"""The ``bq-attempts`` value as an int, or ``None`` when absent or invalid (§5.5)."""
152+
if not isinstance(raw, str) or _ATTEMPTS_PATTERN.fullmatch(raw) is None:
153+
return None
154+
value = int(raw)
155+
return value if value <= _MAX_ATTEMPTS else None
156+
157+
@staticmethod
158+
def _reconcile(
159+
body: str,
160+
redelivery_count: Any,
161+
properties: Optional[Dict[str, str]] = None,
162+
on_routing_mismatch: Optional[Callable[[str, str, Dict[str, Any]], None]] = None,
163+
) -> Tuple[str, str]:
164+
"""Apply §5.2 / §5.7 to a received body; returns ``(body, route_urn)``.
165+
166+
* Routing: a non-blank ``bq-job`` property is the URN the app routes on. It is returned
167+
as ``route_urn`` (set on :attr:`ReceivedMessage.route_urn`); the body's ``job`` /
168+
``urn`` is never rewritten, so the handler, retries and the dead-letter record keep the
169+
producer's original body. A body with no URN gets no ``route_urn`` — it stays
170+
non-conformant and is rejected whatever ``bq-job`` says.
171+
* Attempts: a valid ``bq-attempts`` property replaces the body's value (the body's is
172+
kept when it is absent or invalid), then the result is raised to
173+
``redelivery_count``. Pulsar's count is 0-based (0 on first delivery), so it maps
174+
directly with no -1; the max never lowers a runtime-incremented counter (the runtime
175+
retries by republishing with attempts+1, which resets the redelivery count to 0). The
176+
value is written into the body only when the body already holds an integer
177+
``attempts`` and the value really changes, so a missing / non-integer ``attempts`` is
178+
still rejected by ``EnvelopeCodec.accepts``.
179+
* ``on_routing_mismatch(property_urn, body_urn, envelope)`` is called when the property
180+
and the body name different URNs — only for an envelope the consumer accepts and after
181+
attempts are reconciled (as in the Java and .NET consumers), so it sees what the
182+
handler will get. The hook is optional, separate from any error handling, and any
183+
exception it raises is swallowed — it never affects the message flow.
184+
"""
185+
properties = properties or {}
186+
env = parse_envelope(body)
187+
if not env:
188+
return body, ""
189+
190+
routed = str(properties.get("bq-job") or "").strip()
191+
body_urn = EnvelopeCodec.urn(env)
192+
route_urn = routed if routed and body_urn else ""
193+
194+
current = env.get("attempts")
195+
has_int_attempts = isinstance(current, int) and not isinstance(current, bool)
196+
attempts = current if has_int_attempts else 0
197+
from_property = PulsarTransport._parse_attempts(properties.get("bq-attempts"))
198+
if from_property is not None:
199+
attempts = from_property
136200
try:
137201
rc = int(redelivery_count)
138-
except (ValueError, TypeError):
139-
return body
140-
if rc <= 0:
141-
return body
142-
env = EnvelopeCodec.decode(body)
143-
if not env or rc <= int(env.get("attempts", 0) or 0):
144-
return body
145-
env["attempts"] = rc
146-
return EnvelopeCodec.encode(env)
202+
except (TypeError, ValueError):
203+
rc = 0
204+
if rc > attempts:
205+
attempts = rc
206+
207+
out = body
208+
if has_int_attempts and current != attempts:
209+
env["attempts"] = attempts
210+
out = EnvelopeCodec.encode(env)
211+
212+
if route_urn and route_urn != body_urn and on_routing_mismatch is not None:
213+
if EnvelopeCodec.accepts(env):
214+
try:
215+
on_routing_mismatch(route_urn, body_urn, dict(env))
216+
except Exception: # noqa: BLE001 - a diagnostic hook must never break consumption
217+
pass
218+
return out, route_urn
147219

148220
@staticmethod
149221
def _redelivery_count(message: Any) -> int:
@@ -156,6 +228,12 @@ def _redelivery_count(message: Any) -> int:
156228
except (TypeError, ValueError):
157229
return 0
158230

231+
@staticmethod
232+
def _properties(message: Any) -> Dict[str, str]:
233+
getter = getattr(message, "properties", None)
234+
value = getter() if callable(getter) else getter
235+
return dict(value) if isinstance(value, dict) else {}
236+
159237
@staticmethod
160238
def _payload(message: Any) -> str:
161239
getter = getattr(message, "data", None)
@@ -167,7 +245,14 @@ def _payload(message: Any) -> str:
167245
# -- Transport ----------------------------------------------------------
168246

169247
def publish(self, queue: str, body: str) -> None:
170-
self._producer(queue).send(body.encode("utf-8"), properties=self._projection(body))
248+
self.publish_routed(queue, body, "")
249+
250+
def publish_routed(self, queue: str, body: str, route_urn: str) -> None:
251+
""":meth:`publish` with ``bq-job`` pinned to ``route_urn`` (:class:`RoutedPublisher`);
252+
a blank ``route_urn`` derives it from the body."""
253+
self._producer(queue).send(
254+
body.encode("utf-8"), properties=self._projection(body, route_urn)
255+
)
171256

172257
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
173258
wait = self._receive_timeout_millis
@@ -182,8 +267,13 @@ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
182267
raise
183268
if message is None: # pragma: no cover - defensive (real client raises instead)
184269
return None
185-
body = self._reconcile(self._payload(message), self._redelivery_count(message))
186-
return ReceivedMessage(body=body, queue=queue, handle=message)
270+
body, route_urn = self._reconcile(
271+
self._payload(message),
272+
self._redelivery_count(message),
273+
self._properties(message),
274+
self._on_routing_mismatch,
275+
)
276+
return ReceivedMessage(body=body, queue=queue, handle=message, route_urn=route_urn)
187277

188278
def ack(self, message: ReceivedMessage) -> None:
189279
if message.handle is None:

‎src/babelqueue/redrive.py‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636

3737
from .codec import EnvelopeCodec
3838
from .replay import HEADER_REPLAY_BYPASS
39-
from .transport import HeaderPublisher, ReceivedMessage, Transport
39+
from .transport import HeaderPublisher, ReceivedMessage, Transport, publish_keeping_route
4040

4141
Envelope = Mapping[str, Any]
4242
Select = Callable[[Envelope], bool]
@@ -88,7 +88,7 @@ def redrive(
8888
result = RedriveResult()
8989
for message, envelope in batch:
9090
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
9292
transport.ack(message)
9393
result.skipped += 1
9494
result.items.append(RedriveItem("", "", "", "", dlq, "", False))
@@ -109,7 +109,7 @@ def redrive(
109109
)
110110

111111
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
113113
transport.ack(message)
114114
result.skipped += 1
115115
result.items.append(item)
@@ -119,7 +119,7 @@ def redrive(
119119
item.to = target
120120

121121
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
123123
transport.ack(message)
124124
result.skipped += 1
125125
result.items.append(item)
@@ -130,10 +130,10 @@ def redrive(
130130
reset["attempts"] = 0
131131
try:
132132
item.bypassed = _publish_redriven(
133-
transport, target, EnvelopeCodec.encode(reset), bypass
133+
transport, target, EnvelopeCodec.encode(reset), bypass, message.route_urn
134134
)
135135
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
137137
transport.ack(message)
138138
raise
139139
transport.ack(message)
@@ -156,14 +156,18 @@ def _decoded(body: str) -> Optional[Dict[str, Any]]:
156156
return envelope
157157

158158

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:
160162
"""Re-publish a reset message to ``queue``. When ``bypass`` is set and the transport is a
161163
: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."""
163167
if bypass and isinstance(transport, HeaderPublisher):
164168
transport.publish_with_headers(queue, body, {HEADER_REPLAY_BYPASS: "1"})
165169
return True
166-
transport.publish(queue, body)
170+
publish_keeping_route(transport, queue, body, route_urn)
167171
return False
168172

169173

0 commit comments

Comments
 (0)