Skip to content

Commit efb0142

Browse files
committed
feat(kafka): preserve the record key on retry and dead-letter writes
The original record key now travels with retry republishes, the retry-to-work re-inject and both the envelope and raw dead-letter writes, as broker-bindings section 6 recommends. A null key stays null and an empty key stays empty; partition equality with the source record is not guaranteed.
1 parent 03fe1b9 commit efb0142

6 files changed

Lines changed: 183 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,17 @@ The envelope wire format is versioned separately by `meta.schema_version`
1616
implements it. `KafkaTransport` implements it.
1717
- `KafkaDeliveryError` (a `BabelQueueError`) and the optional `KafkaTransport(delivery_timeout=30.0)`
1818
constructor argument.
19+
- `ReceivedMessage.key` (`Optional[bytes]`, `None` = null key) and an optional `key=` argument on
20+
`SettlePublisher.publish_settle()` / `publish_keeping_route()`. `KafkaTransport.pop()` fills the
21+
key from the consumed record.
1922

2023
### Fixed
24+
- **Kafka: the original record key now survives the retry republish, the envelope DLQ and the
25+
raw (poison / gate-rejection) DLQ.** The key was dropped on every settle write, so a keyed record
26+
lost its key (and with it per-key partition affinity) after the first retry. A null key stays
27+
null, an empty key stays empty, and a key is never synthesised. `redrive` restores to `<queue>.dlq`
28+
also keep it. Partition equality is not guaranteed. The envelope and the `dead_letter` block are
29+
unchanged.
2130
- **Kafka: retry / dead-letter writes are now broker-confirmed before the offset is committed.**
2231
`KafkaTransport.publish_settle()` waits for this write's own delivery report
2332
(`flush(delivery_timeout)`). A delivery error or timeout raises `KafkaDeliveryError`, nothing is

‎src/babelqueue/app.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ def _publish_for(self, received: ReceivedMessage, queue: str, body: str) -> None
415415
or its dead-letter record). Keeps the routing URN the broker property carried on this
416416
delivery when the transport can pin it (:class:`RoutedPublisher`), so a property/body
417417
mismatch does not flip to the body URN on the next attempt or in the DLQ."""
418-
publish_keeping_route(self.transport, queue, body, received.route_urn)
418+
publish_keeping_route(self.transport, queue, body, received.route_urn, received.key)
419419

420420
def _ack_settled(self, received: ReceivedMessage) -> None:
421421
"""Acknowledge a message whose outcome is already final (handled, dead-lettered,

‎src/babelqueue/kafka_transport.py‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
1818
Settlement writes (retry republish, DLQ) go through ``publish_settle`` and wait for the broker's
1919
delivery report (``delivery_timeout``); a failure raises ``KafkaDeliveryError`` and the offset is
20-
not committed. ``publish`` itself stays fire-and-forget. A record value that is not valid UTF-8 is handed to the runtime under a
20+
not committed. ``publish`` itself stays fire-and-forget. Those writes also carry the consumed
21+
record's key unchanged (``ReceivedMessage.key``; null stays null, empty stays empty, never
22+
synthesised). A record value that is not valid UTF-8 is handed to the runtime under a
2123
reserved route so its original bytes are dead-lettered raw (written back with no projected headers;
2224
with the DLQ off, or ``UnknownUrnStrategy.DELETE``, it is dropped and committed). The runtime logs
2325
it as a version-gated message because it reuses that settle path; the route in the log names it.
@@ -219,6 +221,13 @@ def _payload(message: Any) -> str:
219221
return bytes(value).decode("utf-8", "surrogateescape")
220222
return str(value) if value is not None else ""
221223

224+
@staticmethod
225+
def _key(message: Any) -> Optional[bytes]:
226+
"""The record key as the client reports it (``None`` = null key); tolerant of an injected
227+
consumer whose records expose no ``key()``."""
228+
key_of = getattr(message, "key", None)
229+
return key_of() if callable(key_of) else None
230+
222231
@staticmethod
223232
def _is_non_utf8(message: Any) -> bool:
224233
value = message.value()
@@ -238,12 +247,21 @@ def publish(self, queue: str, body: str) -> None:
238247
producer.produce(queue, **kwargs)
239248
producer.poll(0)
240249

241-
def publish_settle(self, queue: str, body: str, route_urn: str = "") -> None:
250+
def publish_settle(
251+
self, queue: str, body: str, route_urn: str = "", key: Optional[bytes] = None
252+
) -> None:
242253
""":class:`~babelqueue.transport.SettlePublisher`: the runtime's settle write (retry
243254
republish, envelope / poison / gate DLQ, ``redrive`` restore). Unlike :meth:`publish` it
244255
waits for the broker's delivery report, because the offset may only be committed once the
245-
write is confirmed; a delivery error or timeout raises :class:`KafkaDeliveryError`."""
246-
self._produce_confirmed(self._producer_(), queue, self._record_kwargs(body, raw_ok=True))
256+
write is confirmed; a delivery error or timeout raises :class:`KafkaDeliveryError`.
257+
258+
``key`` is the consumed record's key (:attr:`ReceivedMessage.key`): it is written back
259+
unchanged, so a retry or DLQ record keeps the original key (``None`` stays a null key, an
260+
empty key stays empty, and no key is ever synthesised). §6."""
261+
kwargs = self._record_kwargs(body, raw_ok=True)
262+
if key is not None:
263+
kwargs["key"] = key
264+
self._produce_confirmed(self._producer_(), queue, kwargs)
247265

248266
@classmethod
249267
def _record_kwargs(cls, body: str, raw_ok: bool = False) -> Dict[str, Any]:
@@ -310,6 +328,7 @@ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
310328
queue=queue,
311329
handle=message,
312330
route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
331+
key=self._key(message),
313332
)
314333
self._pending[queue] = message
315334
return received
@@ -321,12 +340,16 @@ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
321340
"Kafka record on %r is not valid UTF-8; dead-lettering its raw bytes.", queue
322341
)
323342
received = ReceivedMessage(
324-
body=self._payload(message), queue=queue, handle=message, route_urn=NON_UTF8_ROUTE
343+
body=self._payload(message),
344+
queue=queue,
345+
handle=message,
346+
route_urn=NON_UTF8_ROUTE,
347+
key=self._key(message),
325348
)
326349
self._pending[queue] = message
327350
return received
328351
body = self._reconcile(self._payload(message), headers)
329-
received = ReceivedMessage(body=body, queue=queue, handle=message)
352+
received = ReceivedMessage(body=body, queue=queue, handle=message, key=self._key(message))
330353
# Track the record as in flight only once ``pop`` has built it. A decode error above
331354
# escapes before this point and must not leave the record pending: the next ``pop`` would
332355
# seek back to it and fail identically, wedging the partition forever.

‎src/babelqueue/redrive.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def redrive(
8888
result = RedriveResult()
8989
for message, envelope in batch:
9090
if envelope is None:
91-
publish_keeping_route(transport, dlq, message.body, message.route_urn) # restore the poison body; never drop it
91+
publish_keeping_route(transport, dlq, message.body, message.route_urn, message.key) # 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-
publish_keeping_route(transport, dlq, message.body, message.route_urn) # not selected: restore unchanged
112+
publish_keeping_route(transport, dlq, message.body, message.route_urn, message.key) # 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-
publish_keeping_route(transport, dlq, message.body, message.route_urn) # report the plan; restore unchanged
122+
publish_keeping_route(transport, dlq, message.body, message.route_urn, message.key) # report the plan; restore unchanged
123123
transport.ack(message)
124124
result.skipped += 1
125125
result.items.append(item)
@@ -133,7 +133,7 @@ def redrive(
133133
transport, target, EnvelopeCodec.encode(reset), bypass, message.route_urn
134134
)
135135
except Exception:
136-
publish_keeping_route(transport, dlq, message.body, message.route_urn) # restore on a publish failure, then surface
136+
publish_keeping_route(transport, dlq, message.body, message.route_urn, message.key) # restore on a publish failure, then surface
137137
transport.ack(message)
138138
raise
139139
transport.ack(message)

‎src/babelqueue/transport.py‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ class ReceivedMessage:
4343
#: body is left untouched (handler, retry and dead-letter keep the producer's original body).
4444
#: Empty means "route on the body URN" (the default for every other transport).
4545
route_urn: str = ""
46+
#: The broker record key of this delivery, byte-for-byte (``None`` = null key; an empty
47+
#: ``bytes`` is an empty key), for transports that have one (Kafka). The runtime hands it back
48+
#: to a :class:`SettlePublisher` on its own re-publishes (retry, DLQ) so the key is not lost;
49+
#: it never synthesises one. Not part of the envelope; ``None`` for every other transport.
50+
key: Optional[bytes] = None
4651

4752

4853
class Transport(ABC):
@@ -100,18 +105,33 @@ class SettlePublisher(Protocol):
100105
the delivery unsettled. A user's own :meth:`Transport.publish` is never routed here. A transport
101106
that does not implement it gets the :class:`RoutedPublisher` / plain-publish behaviour."""
102107

103-
def publish_settle(self, queue: str, body: str, route_urn: str = "") -> None:
104-
"""Append ``body`` to ``queue`` and return only once the broker confirmed the write."""
108+
def publish_settle(
109+
self, queue: str, body: str, route_urn: str = "", key: Optional[bytes] = None
110+
) -> None:
111+
"""Append ``body`` to ``queue`` and return only once the broker confirmed the write.
112+
113+
``key`` is the consumed record's key (:attr:`ReceivedMessage.key`), carried unchanged
114+
when the broker has record keys; ``None`` means a null key."""
105115
...
106116

107117

108-
def publish_keeping_route(transport: "Transport", queue: str, body: str, route_urn: str = "") -> None:
118+
def publish_keeping_route(
119+
transport: "Transport",
120+
queue: str,
121+
body: str,
122+
route_urn: str = "",
123+
key: Optional[bytes] = None,
124+
) -> None:
109125
"""Publish ``body`` to ``queue``, carrying ``route_urn`` when it is non-empty and the
110126
transport implements :class:`RoutedPublisher`; otherwise a plain :meth:`Transport.publish`.
111-
A :class:`SettlePublisher` transport gets the confirmed settle write instead.
127+
A :class:`SettlePublisher` transport gets the confirmed settle write instead, with the
128+
consumed record's ``key`` when there is one (a null key is simply not passed).
112129
Shared by the runtime's re-publishes (retry, release, dead-letter) and by ``redrive``."""
113130
if isinstance(transport, SettlePublisher):
114-
transport.publish_settle(queue, body, route_urn)
131+
if key is None:
132+
transport.publish_settle(queue, body, route_urn)
133+
else:
134+
transport.publish_settle(queue, body, route_urn, key=key)
115135
elif route_urn and isinstance(transport, RoutedPublisher):
116136
transport.publish_routed(queue, body, route_urn)
117137
else:

‎tests/test_kafka_transport.py‎

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,14 @@ def _assert_no_codec_warnings(case: unittest.TestCase, fn) -> None:
4848

4949

5050
class FakeMessage:
51-
def __init__(self, value, headers=None, err=None) -> None:
51+
def __init__(self, value, headers=None, err=None, key=None) -> None:
5252
self._value = value
5353
self._headers = headers or []
5454
self._err = err
55+
self._key = key
56+
57+
def key(self):
58+
return self._key
5559

5660
def value(self):
5761
return self._value
@@ -72,7 +76,15 @@ def __init__(self) -> None:
7276
self._reports = []
7377

7478
def produce(self, topic, value=None, headers=None, timestamp=None, on_delivery=None, **kw) -> None:
75-
self.produced.append({"topic": topic, "value": value, "headers": headers, "timestamp": timestamp})
79+
self.produced.append(
80+
{
81+
"topic": topic,
82+
"value": value,
83+
"headers": headers,
84+
"timestamp": timestamp,
85+
"key": kw.get("key"),
86+
}
87+
)
7688
if on_delivery is not None:
7789
self._reports.append(on_delivery)
7890

@@ -104,8 +116,8 @@ def close(self) -> None:
104116
class OffsetMessage(FakeMessage):
105117
"""A record that knows its place in a single-partition log."""
106118

107-
def __init__(self, value, headers, offset, topic="orders", partition=0) -> None:
108-
super().__init__(value, headers=headers)
119+
def __init__(self, value, headers, offset, topic="orders", partition=0, key=None) -> None:
120+
super().__init__(value, headers=headers, key=key)
109121
self._offset, self._topic, self._partition = offset, topic, partition
110122

111123
def topic(self):
@@ -638,7 +650,7 @@ def __init__(self, outcomes=(), stray=0) -> None:
638650
self._queued = []
639651

640652
def produce(self, topic, value=None, headers=None, timestamp=None, on_delivery=None, **kw) -> None:
641-
super().produce(topic, value=value, headers=headers, timestamp=timestamp)
653+
super().produce(topic, value=value, headers=headers, timestamp=timestamp, **kw)
642654
outcome = self.outcomes.pop(0) if self.outcomes else "ok"
643655
if on_delivery is not None:
644656
self.confirmed.append(topic)
@@ -864,6 +876,104 @@ def test_non_utf8_attempts_header_does_not_raise(self):
864876
self.assertEqual(EnvelopeCodec.decode(message.body)["attempts"], 2) # the body's own count
865877

866878

879+
class KafkaKeyPreservationTest(unittest.TestCase):
880+
"""The consumed record's key is carried, byte-for-byte, onto every record the runtime itself
881+
writes for it (retry republish, envelope DLQ, gate DLQ, raw non-UTF-8 DLQ). Null stays null,
882+
empty stays empty, and no key is ever made up (§6)."""
883+
884+
KEYS = (b"order-1042", b"", None)
885+
886+
def _run(self, record_value, key, headers=(), **app_kw):
887+
consumer = OffsetConsumer([OffsetMessage(record_value, list(headers), offset=0, key=key)])
888+
producer = ReportingProducer()
889+
app = BabelQueue(transport=transport(producer, consumer), **app_kw)
890+
app._rewind_pause = 0.0 # noqa: SLF001
891+
892+
@app.handler(URN)
893+
def _on(data, meta):
894+
raise RuntimeError("handler failure")
895+
896+
app.consume(queue="orders", max_messages=1)
897+
self.assertEqual(consumer.commits, [0])
898+
self.assertEqual(len(producer.produced), 1)
899+
return producer.produced[0]
900+
901+
def test_pop_surfaces_the_record_key(self):
902+
for key in self.KEYS:
903+
with self.subTest(key=key):
904+
got = transport(consumer=FakeConsumer([FakeMessage(body().encode(), key=key)])).pop("orders")
905+
self.assertEqual(got.key, key)
906+
907+
def test_pop_tolerates_a_record_without_a_key_accessor(self):
908+
class NoKey:
909+
def value(self):
910+
return body().encode()
911+
912+
def headers(self):
913+
return []
914+
915+
def error(self):
916+
return None
917+
918+
self.assertIsNone(transport(consumer=FakeConsumer([NoKey()])).pop("orders").key)
919+
920+
def test_retry_republish_keeps_the_key(self):
921+
for key in self.KEYS:
922+
with self.subTest(key=key):
923+
record = self._run(body().encode(), key, max_attempts=3)
924+
self.assertEqual(record["topic"], "orders")
925+
self.assertEqual(record["key"], key)
926+
927+
def test_envelope_dead_letter_keeps_the_key(self):
928+
for key in self.KEYS:
929+
with self.subTest(key=key):
930+
record = self._run(body().encode(), key, max_attempts=1, dead_letter=True)
931+
self.assertEqual(record["topic"], "orders.dlq")
932+
self.assertEqual(record["key"], key)
933+
934+
def test_gated_dead_letter_keeps_the_key(self):
935+
for key in self.KEYS:
936+
with self.subTest(key=key):
937+
record = self._run(
938+
body().encode(), key, headers=[("bq-schema-version", b"2")], dead_letter=True
939+
)
940+
self.assertEqual(record["topic"], "orders.dlq")
941+
self.assertEqual(record["key"], key)
942+
943+
def test_non_utf8_raw_dead_letter_keeps_the_key(self):
944+
bad = b"\xff\xfe not utf-8 \x80"
945+
for key in self.KEYS:
946+
with self.subTest(key=key):
947+
with self.assertLogs("babelqueue.kafka", level="WARNING"):
948+
record = self._run(bad, key, dead_letter=True)
949+
self.assertEqual(record["topic"], "orders.dlq")
950+
self.assertEqual(record["value"], bad)
951+
self.assertEqual(record["key"], key)
952+
953+
def test_a_null_key_is_not_passed_to_produce(self):
954+
class StrictProducer(FakeProducer):
955+
def produce(self, topic, value=None, headers=None, timestamp=None, on_delivery=None, **kw):
956+
assert "key" not in kw, "a null key must not be synthesised"
957+
super().produce(topic, value=value, headers=headers, timestamp=timestamp, on_delivery=on_delivery)
958+
959+
transport(producer=StrictProducer()).publish_settle("orders", body(), key=None)
960+
961+
def test_plain_publish_does_not_set_a_key(self):
962+
producer = FakeProducer()
963+
transport(producer=producer).publish("orders", body())
964+
self.assertIsNone(producer.produced[0]["key"])
965+
966+
def test_redrive_restore_keeps_the_key(self):
967+
from babelqueue import redrive
968+
969+
consumer = FakeConsumer([FakeMessage(b"not json at all", key=b"k-7")])
970+
producer = FakeProducer()
971+
result = redrive.redrive(transport(producer, consumer), "orders.dlq", timeout=0)
972+
self.assertEqual(result.skipped, 1)
973+
self.assertEqual(producer.produced[0]["topic"], "orders.dlq")
974+
self.assertEqual(producer.produced[0]["key"], b"k-7")
975+
976+
867977
class KafkaSchemeTest(unittest.TestCase):
868978
def test_make_transport_routes_kafka_scheme_lazily(self):
869979
# Construction is lazy (no client built), so it succeeds with no confluent-kafka.

0 commit comments

Comments
 (0)