babelqueue 1.17.0__tar.gz → 1.18.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 (96) hide show
  1. {babelqueue-1.17.0 → babelqueue-1.18.0}/CHANGELOG.md +29 -0
  2. {babelqueue-1.17.0 → babelqueue-1.18.0}/PKG-INFO +1 -1
  3. {babelqueue-1.17.0 → babelqueue-1.18.0}/pyproject.toml +1 -1
  4. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/__init__.py +3 -1
  5. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/kafka_transport.py +198 -29
  6. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/redrive.py +39 -12
  7. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/transport.py +25 -1
  8. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fakes/confluent_kafka/__init__.py +12 -4
  9. babelqueue-1.18.0/tests/test_kafka_integration.py +311 -0
  10. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_kafka_integration_logic.py +1 -1
  11. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_kafka_transport.py +424 -1
  12. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_redrive.py +40 -0
  13. babelqueue-1.17.0/tests/test_kafka_integration.py +0 -169
  14. {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/FUNDING.yml +0 -0
  15. {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/dependabot.yml +0 -0
  16. {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/workflows/ci.yml +0 -0
  17. {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/workflows/release.yml +0 -0
  18. {babelqueue-1.17.0 → babelqueue-1.18.0}/.gitignore +0 -0
  19. {babelqueue-1.17.0 → babelqueue-1.18.0}/LICENSE +0 -0
  20. {babelqueue-1.17.0 → babelqueue-1.18.0}/README.md +0 -0
  21. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/app.py +0 -0
  22. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/artemis_transport.py +0 -0
  23. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/asb_transport.py +0 -0
  24. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/celery.py +0 -0
  25. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/codec.py +0 -0
  26. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/contracts.py +0 -0
  27. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/dead_letter.py +0 -0
  28. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/__init__.py +0 -0
  29. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/apps.py +0 -0
  30. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/management/__init__.py +0 -0
  31. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  32. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  33. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/exceptions.py +0 -0
  34. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/gdpr.py +0 -0
  35. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/headers.py +0 -0
  36. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/idempotency.py +0 -0
  37. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/otel.py +0 -0
  38. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/outbox.py +0 -0
  39. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/pika_transport.py +0 -0
  40. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/pulsar_transport.py +0 -0
  41. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/py.typed +0 -0
  42. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/redis_transport.py +0 -0
  43. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/replay.py +0 -0
  44. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/routing.py +0 -0
  45. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/schema.py +0 -0
  46. {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/sqs_transport.py +0 -0
  47. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/CONFORMANCE_VERSION +0 -0
  48. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
  49. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  50. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
  51. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
  52. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
  53. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
  54. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
  55. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
  56. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  57. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  58. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/order-created.json +0 -0
  59. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  60. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
  61. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
  62. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
  63. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  64. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/manifest.json +0 -0
  65. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  66. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fakes/confluent_kafka/admin.py +0 -0
  67. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fixtures/dead-lettered.json +0 -0
  68. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fixtures/order-created.json +0 -0
  69. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_app.py +0 -0
  70. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_artemis_conformance.py +0 -0
  71. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_artemis_transport.py +0 -0
  72. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_asb_conformance.py +0 -0
  73. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_asb_transport.py +0 -0
  74. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_behaviour_conformance.py +0 -0
  75. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_celery.py +0 -0
  76. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_codec.py +0 -0
  77. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_conformance.py +0 -0
  78. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_dead_letter.py +0 -0
  79. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_django.py +0 -0
  80. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_gdpr.py +0 -0
  81. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_headers.py +0 -0
  82. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_idempotency.py +0 -0
  83. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_kafka_conformance.py +0 -0
  84. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_otel.py +0 -0
  85. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_outbox.py +0 -0
  86. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_overhead.py +0 -0
  87. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_pika_transport.py +0 -0
  88. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_pulsar_conformance.py +0 -0
  89. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_pulsar_transport.py +0 -0
  90. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_redis_transport.py +0 -0
  91. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_replay.py +0 -0
  92. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_schema.py +0 -0
  93. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_schema_conformance.py +0 -0
  94. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_shutdown.py +0 -0
  95. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_sqs_conformance.py +0 -0
  96. {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_sqs_transport.py +0 -0
@@ -9,6 +9,35 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.18.0] - 2026-10-11
13
+
14
+ ### Added
15
+ - `BatchPopper` (optional transport capability, exported from `babelqueue`) with
16
+ `pop_batch(queue, max=0, timeout=1.0)`: take several deliveries at once and hold them un-settled.
17
+ `KafkaTransport` implements it; `redrive` uses it when a transport offers it.
18
+
19
+ ### Fixed
20
+ - **`redrive` no longer loops forever (default `max=0`) or repeats records (`max>1`) on Kafka.**
21
+ `redrive` drains the DLQ before it processes it, but `KafkaTransport.pop` first seeks back to the
22
+ previous un-acked record, so every pop returned the first record again. The drain now takes the
23
+ batch through `pop_batch` (one rewind up front, none between the polls), and `KafkaTransport`
24
+ tracks several in-flight records: acks are committed in offset order, so the cumulative commit
25
+ never passes a record that is still unsettled, and an abandoned batch is rewound to its lowest
26
+ offset by the next `pop`. The restore writes are not re-read in the same call. Other transports
27
+ and the single-record `pop`/`ack`/`rewind` behaviour are unchanged. No wire change.
28
+ A record whose envelope carries an `attempts` the runtime cannot read as an integer (a foreign
29
+ producer: `null`, `""`, a list/object, a bool, `"three"`, `NaN`/`Infinity`; an absent key is just 0) no longer raises out
30
+ of `pop` or the restore publish. `KafkaTransport.pop` hands it over under a reserved poison route
31
+ (like a non-UTF-8 record), so the runtime dead-letters it raw without running a handler (a failing
32
+ handler can no longer wedge the partition) and `redrive` restores it to the DLQ instead of
33
+ replaying it. The redundant `bq-attempts` header is left out of such a write.
34
+ - **`redrive` now carries the DLQ record key onto the redriven record.** The restore writes back to
35
+ the DLQ already kept the key; the redrive write to the work topic did not, so a replayed Kafka
36
+ record lost its key (and per-key partition affinity). It now goes through
37
+ `publish_keeping_route(..., key=message.key)`, byte-for-byte (null stays null, empty stays empty),
38
+ per contract §6.5 "Record key on republish". Non-keyed transports behave exactly as before; the
39
+ `bypass=True` header path still carries no key. No wire change.
40
+
12
41
  ## [1.17.0] - 2026-10-11
13
42
 
14
43
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: babelqueue
3
- Version: 1.17.0
3
+ Version: 1.18.0
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.17.0"
7
+ version = "1.18.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"
@@ -30,6 +30,7 @@ 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
32
  from .transport import (
33
+ BatchPopper,
33
34
  HeaderPublisher,
34
35
  InMemoryTransport,
35
36
  ReceivedMessage,
@@ -40,7 +41,7 @@ from .transport import (
40
41
  Transport,
41
42
  )
42
43
 
43
- __version__ = "1.17.0"
44
+ __version__ = "1.18.0"
44
45
 
45
46
  __all__ = [
46
47
  "BabelQueue",
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "Transport",
54
55
  "InMemoryTransport",
55
56
  "ReceivedMessage",
57
+ "BatchPopper",
56
58
  "HeaderPublisher",
57
59
  "Redeliverer",
58
60
  "Rewinder",
@@ -41,7 +41,12 @@ from urllib.parse import quote
41
41
 
42
42
  from .codec import SCHEMA_VERSION, EnvelopeCodec, parse_envelope
43
43
  from .exceptions import BabelQueueError
44
- from .transport import ReceivedMessage, Transport, truncate_declared_version
44
+ from .transport import (
45
+ NON_NUMERIC_ATTEMPTS_ROUTE,
46
+ ReceivedMessage,
47
+ Transport,
48
+ truncate_declared_version,
49
+ )
45
50
 
46
51
  _logger = logging.getLogger("babelqueue.kafka")
47
52
 
@@ -72,6 +77,20 @@ class KafkaDeliveryError(BabelQueueError):
72
77
  """A retry / dead-letter write was not confirmed by the broker (delivery error or timeout)."""
73
78
 
74
79
 
80
+ def _runtime_attempts(env: Any) -> int:
81
+ """The envelope's ``attempts`` exactly as the runtime's retry path reads it
82
+ (``int(envelope.get("attempts", 0))``): an absent key is 0. Raises ``ValueError`` for every
83
+ value that read would choke on — null, ``""``, a list/dict, ``"three"``, ``inf``/``nan`` — and
84
+ for a bool, which is no counter at all although ``int()`` happens to accept it."""
85
+ value = env.get("attempts", 0)
86
+ if isinstance(value, bool):
87
+ raise ValueError("attempts is a bool")
88
+ try:
89
+ return int(value)
90
+ except (TypeError, OverflowError) as exc:
91
+ raise ValueError("attempts is not an integer") from exc
92
+
93
+
75
94
  def _brokers_from_url(url: str) -> str:
76
95
  rest = url.split("://", 1)[1] if "://" in url else url
77
96
  return rest.split("/", 1)[0] or "localhost:9092"
@@ -95,8 +114,13 @@ class KafkaTransport(Transport):
95
114
  self._producer = producer
96
115
  self._consumer_factory = consumer_factory
97
116
  self._consumers: Dict[str, Any] = {}
98
- # Per queue: the record handed out by ``pop`` that has been neither acked nor rewound yet.
99
- self._pending: Dict[str, Any] = {}
117
+ # Per queue: the records handed out that have been neither acked nor rewound yet (one for
118
+ # ``pop``; several for ``pop_batch``, which a drain-first consumer such as ``redrive`` uses).
119
+ self._pending: Dict[str, List[Any]] = {}
120
+ # Per queue: records acked while an earlier record on the same partition was still in
121
+ # flight. Kafka's commit is a cumulative per-partition position, so committing them now
122
+ # would pass the unsettled record; they are committed once that record settles.
123
+ self._settled: Dict[str, List[Any]] = {}
100
124
 
101
125
  # -- helpers ------------------------------------------------------------
102
126
 
@@ -156,7 +180,12 @@ class KafkaTransport(Transport):
156
180
  if meta.get("schema_version") is not None:
157
181
  headers.append(("bq-schema-version", str(meta["schema_version"]).encode("utf-8")))
158
182
  add("bq-source-lang", meta.get("lang"))
159
- headers.append(("bq-attempts", str(int(env.get("attempts", 0) or 0)).encode("utf-8")))
183
+ try:
184
+ headers.append(("bq-attempts", str(_runtime_attempts(env)).encode("utf-8")))
185
+ except ValueError:
186
+ # A foreign producer's non-numeric ``attempts``: leave the redundant header out (the
187
+ # body is authoritative then) rather than fail the publish and strand the record.
188
+ pass
160
189
  return headers
161
190
 
162
191
  @staticmethod
@@ -166,7 +195,14 @@ class KafkaTransport(Transport):
166
195
  env = EnvelopeCodec.decode(body)
167
196
  if not env:
168
197
  return body
169
- attempts = int(env.get("attempts", 0) or 0)
198
+ try:
199
+ body_attempts = _runtime_attempts(env)
200
+ except ValueError:
201
+ # A foreign producer wrote a non-numeric ``attempts``: there is nothing to reconcile
202
+ # against, and raising out of ``pop`` would skip the record unrestored. Pass the body
203
+ # through untouched; the runtime / ``redrive`` handle it like any other record.
204
+ return body
205
+ attempts = body_attempts
170
206
  for key, value in headers or []:
171
207
  if key == "bq-attempts":
172
208
  # ``replace``: a non-UTF-8 header value must not raise out of ``pop``; it simply
@@ -181,7 +217,7 @@ class KafkaTransport(Transport):
181
217
  except (ValueError, TypeError):
182
218
  pass
183
219
  break
184
- if attempts == int(env.get("attempts", 0) or 0):
220
+ if attempts == body_attempts:
185
221
  return body
186
222
  env["attempts"] = attempts
187
223
  return EnvelopeCodec.encode(env)
@@ -228,6 +264,20 @@ class KafkaTransport(Transport):
228
264
  key_of = getattr(message, "key", None)
229
265
  return key_of() if callable(key_of) else None
230
266
 
267
+ @staticmethod
268
+ def _has_non_numeric_attempts(body: str) -> bool:
269
+ """Whether the body is an envelope whose ``attempts`` the runtime's retry path could
270
+ not read (see ``_runtime_attempts``), so such a record could never be retried. An absent
271
+ ``attempts`` is a plain 0, not a poison."""
272
+ env = parse_envelope(body)
273
+ if not env or "attempts" not in env:
274
+ return False
275
+ try:
276
+ _runtime_attempts(env)
277
+ except ValueError:
278
+ return True
279
+ return False
280
+
231
281
  @staticmethod
232
282
  def _is_non_utf8(message: Any) -> bool:
233
283
  value = message.value()
@@ -303,8 +353,47 @@ class KafkaTransport(Transport):
303
353
  raise KafkaDeliveryError(f"Kafka delivery to {queue!r} failed: {outcome['error']}")
304
354
 
305
355
  def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
306
- wait = timeout if timeout and timeout > 0 else 1.0
307
356
  self._rewind_abandoned(queue)
357
+ return self._poll_one(queue, timeout)
358
+
359
+ def pop_batch(
360
+ self, queue: str, max: int = 0, timeout: float = 1.0
361
+ ) -> List[ReceivedMessage]:
362
+ """Up to ``max`` records (all currently available when ``max`` is 0), all held in flight
363
+ (:class:`~babelqueue.transport.BatchPopper`).
364
+
365
+ A drain-first consumer pops every record before it settles any, so the abandoned-record
366
+ rewind that every :meth:`pop` starts with must run once, up front — not between the polls,
367
+ where it would seek back to the record just handed out and return it again. The records
368
+ are then acked in any order; commits still never pass an unsettled one (:meth:`ack`).
369
+ """
370
+ self._rewind_abandoned(queue)
371
+ batch: List[ReceivedMessage] = []
372
+ while max == 0 or len(batch) < max:
373
+ try:
374
+ received = self._poll_one(queue, timeout)
375
+ except Exception:
376
+ # A record ``pop`` cannot build (e.g. a malformed ``attempts``) is not tracked as in
377
+ # flight (see ``_poll_one``). Raising here would abandon the records already held,
378
+ # and the next call would rewind to them and hit the same record again: the
379
+ # partition could never be drained. Hand out what was taken instead, exactly as the
380
+ # single-record ``pop`` moves past it on its next call. With nothing taken yet there
381
+ # is nothing to lose, so the error surfaces.
382
+ if not batch:
383
+ raise
384
+ _logger.exception(
385
+ "Kafka record on %r could not be decoded; ending the batch at %d record(s).",
386
+ queue,
387
+ len(batch),
388
+ )
389
+ break
390
+ if received is None:
391
+ break
392
+ batch.append(received)
393
+ return batch
394
+
395
+ def _poll_one(self, queue: str, timeout: float) -> Optional[ReceivedMessage]:
396
+ wait = timeout if timeout and timeout > 0 else 1.0
308
397
  message = self._consumer(queue).poll(wait)
309
398
  if message is None or message.error() is not None:
310
399
  return None
@@ -330,7 +419,7 @@ class KafkaTransport(Transport):
330
419
  route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
331
420
  key=self._key(message),
332
421
  )
333
- self._pending[queue] = message
422
+ self._pending.setdefault(queue, []).append(message)
334
423
  return received
335
424
  if self._is_non_utf8(message):
336
425
  # The value cannot be decoded, so there is no envelope to retry or annotate: hand it
@@ -346,22 +435,81 @@ class KafkaTransport(Transport):
346
435
  route_urn=NON_UTF8_ROUTE,
347
436
  key=self._key(message),
348
437
  )
349
- self._pending[queue] = message
438
+ self._pending.setdefault(queue, []).append(message)
439
+ return received
440
+ payload = self._payload(message)
441
+ if self._has_non_numeric_attempts(payload):
442
+ # A foreign producer wrote a non-numeric ``attempts``. The runtime could not retry it
443
+ # (it needs an integer counter) and a failing handler would wedge the partition, so it
444
+ # is handed over under the reserved route like a non-UTF-8 record: dead-lettered raw,
445
+ # never given to a handler, and restored (not replayed) by ``redrive``.
446
+ _logger.warning(
447
+ "Kafka record on %r carries a non-numeric attempts; dead-lettering it raw.", queue
448
+ )
449
+ received = ReceivedMessage(
450
+ body=payload,
451
+ queue=queue,
452
+ handle=message,
453
+ route_urn=NON_NUMERIC_ATTEMPTS_ROUTE,
454
+ key=self._key(message),
455
+ )
456
+ self._pending.setdefault(queue, []).append(message)
350
457
  return received
351
- body = self._reconcile(self._payload(message), headers)
458
+ body = self._reconcile(payload, headers)
352
459
  received = ReceivedMessage(body=body, queue=queue, handle=message, key=self._key(message))
353
460
  # Track the record as in flight only once ``pop`` has built it. A decode error above
354
461
  # escapes before this point and must not leave the record pending: the next ``pop`` would
355
462
  # seek back to it and fail identically, wedging the partition forever.
356
- self._pending[queue] = message
463
+ self._pending.setdefault(queue, []).append(message)
357
464
  return received
358
465
 
466
+ @staticmethod
467
+ def _partition_of(handle: Any) -> Tuple[Any, Any]:
468
+ # A handle without a position (a stand-in that is not a Kafka record) is its own bucket.
469
+ try:
470
+ return (handle.topic(), handle.partition())
471
+ except AttributeError:
472
+ return (None, None)
473
+
474
+ @staticmethod
475
+ def _offset_of(handle: Any) -> int:
476
+ try:
477
+ return int(handle.offset())
478
+ except AttributeError:
479
+ return -1
480
+
359
481
  def ack(self, message: ReceivedMessage) -> None:
360
- if message.handle is None:
482
+ handle = message.handle
483
+ if handle is None:
361
484
  return
485
+ queue = message.queue
362
486
  # Settled: from here on the record is no longer "in flight", whatever the commit does.
363
- self._pending.pop(message.queue, None)
364
- self._consumer(message.queue).commit(message=message.handle, asynchronous=False)
487
+ pending = [h for h in self._pending.get(queue, []) if h is not handle]
488
+ if pending:
489
+ self._pending[queue] = pending
490
+ else:
491
+ self._pending.pop(queue, None)
492
+ settled = self._settled.get(queue, []) + [handle]
493
+ # The commit is a cumulative per-partition position: it may only reach as far as the lowest
494
+ # record on that partition that is still in flight.
495
+ partition = self._partition_of(handle)
496
+ floor = min(
497
+ (self._offset_of(h) for h in pending if self._partition_of(h) == partition), default=None
498
+ )
499
+ committable = [
500
+ h
501
+ for h in settled
502
+ if self._partition_of(h) == partition and (floor is None or self._offset_of(h) < floor)
503
+ ]
504
+ remaining = [h for h in settled if not any(h is c for c in committable)]
505
+ if remaining:
506
+ self._settled[queue] = remaining
507
+ else:
508
+ self._settled.pop(queue, None)
509
+ if not committable:
510
+ return
511
+ top = max(committable, key=self._offset_of)
512
+ self._consumer(queue).commit(message=top, asynchronous=False)
365
513
 
366
514
  def rewind(self, message: ReceivedMessage) -> None:
367
515
  """Seek the partition back to ``message``'s offset so the next ``pop`` returns it again
@@ -371,27 +519,48 @@ class KafkaTransport(Transport):
371
519
  if handle is None:
372
520
  return
373
521
  self._seek_back(message.queue, handle)
374
- if self._pending.get(message.queue) is handle:
375
- del self._pending[message.queue]
522
+ self._forget_from(message.queue, self._partition_of(handle), self._offset_of(handle))
523
+
524
+ def _forget_from(self, queue: str, partition: Tuple[Any, Any], offset: int) -> None:
525
+ """Drop the in-flight and deferred-settled records of ``partition`` at or after ``offset``:
526
+ the seek made the broker redeliver them."""
527
+ for book in (self._pending, self._settled):
528
+ kept = [
529
+ h
530
+ for h in book.get(queue, [])
531
+ if self._partition_of(h) != partition or self._offset_of(h) < offset
532
+ ]
533
+ if kept:
534
+ book[queue] = kept
535
+ else:
536
+ book.pop(queue, None)
376
537
 
377
538
  def _rewind_abandoned(self, queue: str) -> None:
378
- """Re-seek a record that ``pop`` handed out but that was never acked or rewound.
539
+ """Re-seek the records that were handed out but never acked or rewound.
379
540
 
380
541
  That happens when the runtime's settlement raised (a failed retry / DLQ / release
381
- ``publish``): ``poll`` has already advanced the in-memory position, so without this the
382
- next ``pop`` (a restarted consume loop) would return the *following* record, and acking it
383
- would commit past the unsettled one — losing it. Seeking back makes it redeliver instead.
542
+ ``publish``) or a drain-first consumer stopped midway: ``poll`` has already advanced the
543
+ in-memory position, so without this the next ``pop`` (a restarted consume loop) would
544
+ return the *following* record, and acking it would commit past the unsettled one — losing
545
+ it. Seeking back to the lowest such record per partition makes them redeliver instead.
384
546
  """
385
- handle = self._pending.get(queue)
386
- if handle is None:
547
+ pending = self._pending.get(queue)
548
+ if not pending:
387
549
  return
388
- try:
389
- self._seek_back(queue, handle)
390
- except Exception: # noqa: BLE001 - already logged, naming the record
391
- # A seek can only fail here for a partition that is no longer assigned (rebalance); the
392
- # group then resets the position to the committed offset, which is that record. Do not
393
- # wedge the consumer on it.
394
- pass
550
+ lowest: Dict[Tuple[Any, Any], Any] = {}
551
+ for handle in pending:
552
+ partition = self._partition_of(handle)
553
+ if partition not in lowest or self._offset_of(handle) < self._offset_of(lowest[partition]):
554
+ lowest[partition] = handle
555
+ for partition, handle in lowest.items():
556
+ try:
557
+ self._seek_back(queue, handle)
558
+ except Exception: # noqa: BLE001 - already logged, naming the record
559
+ # A seek can only fail here for a partition that is no longer assigned (rebalance);
560
+ # the group then resets the position to the committed offset, which is that
561
+ # record. Do not wedge the consumer on it.
562
+ pass
563
+ self._forget_from(queue, partition, self._offset_of(handle))
395
564
  self._pending.pop(queue, None)
396
565
 
397
566
  def _seek_back(self, queue: str, handle: Any) -> None:
@@ -36,7 +36,14 @@ 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, publish_keeping_route
39
+ from .transport import (
40
+ NON_NUMERIC_ATTEMPTS_ROUTE,
41
+ BatchPopper,
42
+ HeaderPublisher,
43
+ ReceivedMessage,
44
+ Transport,
45
+ publish_keeping_route,
46
+ )
40
47
 
41
48
  Envelope = Mapping[str, Any]
42
49
  Select = Callable[[Envelope], bool]
@@ -78,16 +85,27 @@ def redrive(
78
85
  ) -> RedriveResult:
79
86
  """Move dead-lettered messages off ``dlq`` and replay them; see the module docstring."""
80
87
  # Drain up to ``max`` messages (or all available) before processing any of them.
81
- batch: List[Tuple[ReceivedMessage, Optional[Dict[str, Any]]]] = []
82
- while max == 0 or len(batch) < max:
83
- message = transport.pop(dlq, timeout)
84
- if message is None:
85
- break
86
- batch.append((message, _decoded(message.body)))
88
+ # A transport whose ``pop`` takes back an un-settled delivery (Kafka) hands the whole batch out
89
+ # in one call instead, so the earlier messages are not popped a second time while the drain runs.
90
+ if isinstance(transport, BatchPopper):
91
+ popped = transport.pop_batch(dlq, max, timeout)
92
+ else:
93
+ popped = []
94
+ while max == 0 or len(popped) < max:
95
+ message = transport.pop(dlq, timeout)
96
+ if message is None:
97
+ break
98
+ popped.append(message)
99
+ batch: List[Tuple[ReceivedMessage, Optional[Dict[str, Any]]]] = [
100
+ (message, _decoded(message.body)) for message in popped
101
+ ]
87
102
 
88
103
  result = RedriveResult()
89
104
  for message, envelope in batch:
90
- if envelope is None:
105
+ # A record the transport flagged as non-numeric-``attempts`` is not redrivable either: a
106
+ # replay would reset it to a valid counter and quietly repair a foreign producer's bad
107
+ # envelope, so it is restored to the DLQ like any other poison body.
108
+ if envelope is None or message.route_urn == NON_NUMERIC_ATTEMPTS_ROUTE:
91
109
  publish_keeping_route(transport, dlq, message.body, message.route_urn, message.key) # restore the poison body; never drop it
92
110
  transport.ack(message)
93
111
  result.skipped += 1
@@ -130,7 +148,7 @@ def redrive(
130
148
  reset["attempts"] = 0
131
149
  try:
132
150
  item.bypassed = _publish_redriven(
133
- transport, target, EnvelopeCodec.encode(reset), bypass, message.route_urn
151
+ transport, target, EnvelopeCodec.encode(reset), bypass, message.route_urn, message.key
134
152
  )
135
153
  except Exception:
136
154
  publish_keeping_route(transport, dlq, message.body, message.route_urn, message.key) # restore on a publish failure, then surface
@@ -157,17 +175,26 @@ def _decoded(body: str) -> Optional[Dict[str, Any]]:
157
175
 
158
176
 
159
177
  def _publish_redriven(
160
- transport: Transport, queue: str, body: str, bypass: bool, route_urn: str = ""
178
+ transport: Transport,
179
+ queue: str,
180
+ body: str,
181
+ bypass: bool,
182
+ route_urn: str = "",
183
+ key: Optional[bytes] = None,
161
184
  ) -> bool:
162
185
  """Re-publish a reset message to ``queue``. When ``bypass`` is set and the transport is a
163
186
  :class:`~babelqueue.transport.HeaderPublisher`, stamp the ``bq-replay-bypass`` header and
164
187
  return True; otherwise publish plainly and return False. Either way a non-empty ``route_urn``
165
188
  (the dead-lettered message's routing property) is carried back through
166
- :class:`~babelqueue.transport.RoutedPublisher` when the transport implements it."""
189
+ :class:`~babelqueue.transport.RoutedPublisher` when the transport implements it, and the DLQ
190
+ record's ``key`` (:attr:`ReceivedMessage.key`; Kafka, contract §6.5 "Record key on republish")
191
+ is carried byte-for-byte through :class:`~babelqueue.transport.SettlePublisher` — a null key
192
+ stays null, an empty key stays empty. The bypass-header path carries no record key (as in the
193
+ Go reference)."""
167
194
  if bypass and isinstance(transport, HeaderPublisher):
168
195
  transport.publish_with_headers(queue, body, {HEADER_REPLAY_BYPASS: "1"})
169
196
  return True
170
- publish_keeping_route(transport, queue, body, route_urn)
197
+ publish_keeping_route(transport, queue, body, route_urn, key)
171
198
  return False
172
199
 
173
200
 
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from abc import ABC, abstractmethod
10
10
  from collections import defaultdict, deque
11
11
  from dataclasses import dataclass, field
12
- from typing import Any, Deque, Dict, Optional, Protocol, runtime_checkable
12
+ from typing import Any, Deque, Dict, List, Optional, Protocol, runtime_checkable
13
13
 
14
14
  from .exceptions import BabelQueueError
15
15
 
@@ -172,6 +172,30 @@ class Rewinder(Protocol):
172
172
  ...
173
173
 
174
174
 
175
+ #: Reserved ``route_urn`` of a record whose envelope carries a non-numeric ``attempts`` (a foreign
176
+ #: producer). It sits under the version-gated prefix, so the runtime dead-letters it directly, raw
177
+ #: and never retried (retrying needs a numeric attempt counter); ``redrive`` restores it to the DLQ
178
+ #: instead of replaying it to a work topic. Delivered by :class:`~babelqueue.kafka_transport.KafkaTransport`.
179
+ NON_NUMERIC_ATTEMPTS_ROUTE = "babelqueue:unsupported-schema-version:non-numeric-attempts"
180
+
181
+
182
+ @runtime_checkable
183
+ class BatchPopper(Protocol):
184
+ """Optional :class:`Transport` capability: take several deliveries at once and hold them.
185
+
186
+ Needed by brokers whose :meth:`Transport.pop` treats a delivery that was neither acked nor
187
+ rewound as abandoned and takes it back on the next call (Kafka: the position is cumulative, so
188
+ an unsettled record must come back rather than be skipped). A drain-first consumer such as
189
+ :func:`babelqueue.redrive.redrive` pops every message before it settles any of them, and must
190
+ not have the earlier ones taken back by each following pop. :meth:`pop_batch` hands out the
191
+ whole batch in one call; the messages may then be settled in any order, and an implementation
192
+ must not let a commit pass one that is still unsettled."""
193
+
194
+ def pop_batch(self, queue: str, max: int = 0, timeout: float = 1.0) -> List[ReceivedMessage]:
195
+ """Up to ``max`` messages (all currently available when ``max`` is 0), held un-settled."""
196
+ ...
197
+
198
+
175
199
  class InMemoryTransport(Transport):
176
200
  """In-process transport for tests and broker-free local runs (``memory://``)."""
177
201
 
@@ -8,6 +8,8 @@ _LOCK = threading.RLock()
8
8
  TOPICS = {} # topic -> list[Message]
9
9
  COMMITTED = {} # (group, topic) -> next offset to read
10
10
 
11
+ OFFSET_BEGINNING = -2
12
+
11
13
 
12
14
  class TopicPartition:
13
15
  def __init__(self, topic, partition=0, offset=-1001):
@@ -15,9 +17,10 @@ class TopicPartition:
15
17
 
16
18
 
17
19
  class Message:
18
- def __init__(self, topic, offset, value, headers):
19
- self._t, self._o, self._v, self._h = topic, offset, value, headers
20
+ def __init__(self, topic, offset, value, headers, key=None):
21
+ self._t, self._o, self._v, self._h, self._k = topic, offset, value, headers, key
20
22
 
23
+ def key(self): return self._k # None = null key, b"" = empty key (kept distinct)
21
24
  def value(self): return self._v
22
25
  def headers(self): return self._h
23
26
  def error(self): return None
@@ -29,10 +32,10 @@ class Message:
29
32
  class Producer:
30
33
  def __init__(self, conf): self._reports = []
31
34
 
32
- def produce(self, topic, value=None, headers=None, timestamp=None, on_delivery=None, **kw):
35
+ def produce(self, topic, value=None, headers=None, timestamp=None, on_delivery=None, key=None, **kw):
33
36
  with _LOCK:
34
37
  log = TOPICS.setdefault(topic, []) # auto-create, like the real broker default
35
- log.append(Message(topic, len(log), value, list(headers or [])))
38
+ log.append(Message(topic, len(log), value, list(headers or []), key))
36
39
  if on_delivery is not None:
37
40
  self._reports.append(on_delivery)
38
41
 
@@ -56,6 +59,11 @@ class Consumer:
56
59
  self.topic = topics[0]
57
60
  self.position = COMMITTED.get((self.group, self.topic), 0)
58
61
 
62
+ def assign(self, partitions):
63
+ tp = partitions[0]
64
+ self.topic = tp.topic
65
+ self.position = 0 if tp.offset == OFFSET_BEGINNING else tp.offset
66
+
59
67
  def poll(self, timeout=None):
60
68
  with _LOCK:
61
69
  log = TOPICS.get(self.topic, [])