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.
- {babelqueue-1.17.0 → babelqueue-1.18.0}/CHANGELOG.md +29 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/PKG-INFO +1 -1
- {babelqueue-1.17.0 → babelqueue-1.18.0}/pyproject.toml +1 -1
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/__init__.py +3 -1
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/kafka_transport.py +198 -29
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/redrive.py +39 -12
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/transport.py +25 -1
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fakes/confluent_kafka/__init__.py +12 -4
- babelqueue-1.18.0/tests/test_kafka_integration.py +311 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_kafka_integration_logic.py +1 -1
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_kafka_transport.py +424 -1
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_redrive.py +40 -0
- babelqueue-1.17.0/tests/test_kafka_integration.py +0 -169
- {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/dependabot.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/.gitignore +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/LICENSE +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/README.md +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/CONFORMANCE_VERSION +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fakes/confluent_kafka/admin.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_app.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_behaviour_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_celery.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_codec.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_django.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_gdpr.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_headers.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_otel.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_outbox.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_replay.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_schema.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_shutdown.py +0 -0
- {babelqueue-1.17.0 → babelqueue-1.18.0}/tests/test_sqs_conformance.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ==
|
|
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[
|
|
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[
|
|
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(
|
|
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[
|
|
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
|
-
|
|
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.
|
|
364
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
382
|
-
next ``pop`` (a restarted consume loop) would
|
|
383
|
-
would commit past the unsettled one — losing
|
|
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
|
-
|
|
386
|
-
if
|
|
547
|
+
pending = self._pending.get(queue)
|
|
548
|
+
if not pending:
|
|
387
549
|
return
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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,
|
|
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, [])
|