babelqueue 1.10.0__tar.gz → 1.12.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.10.0 → babelqueue-1.12.0}/CHANGELOG.md +50 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/PKG-INFO +78 -1
- {babelqueue-1.10.0 → babelqueue-1.12.0}/README.md +77 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/pyproject.toml +1 -1
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/__init__.py +20 -2
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/app.py +43 -3
- babelqueue-1.12.0/src/babelqueue/headers.py +70 -0
- babelqueue-1.12.0/src/babelqueue/otel.py +265 -0
- babelqueue-1.12.0/src/babelqueue/outbox.py +360 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/pika_transport.py +46 -10
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/redis_transport.py +103 -2
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/sqs_transport.py +60 -2
- babelqueue-1.12.0/tests/test_headers.py +112 -0
- babelqueue-1.12.0/tests/test_otel.py +263 -0
- babelqueue-1.12.0/tests/test_outbox.py +263 -0
- babelqueue-1.12.0/tests/test_pika_transport.py +195 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_redis_transport.py +110 -2
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_sqs_transport.py +69 -1
- babelqueue-1.10.0/src/babelqueue/otel.py +0 -176
- babelqueue-1.10.0/tests/test_otel.py +0 -131
- babelqueue-1.10.0/tests/test_pika_transport.py +0 -98
- {babelqueue-1.10.0 → babelqueue-1.12.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/.gitignore +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/LICENSE +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_app.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_celery.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_codec.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_django.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_replay.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_schema.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.10.0 → babelqueue-1.12.0}/tests/test_sqs_conformance.py +0 -0
|
@@ -9,6 +9,56 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.12.0] - 2026-06-21
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Transactional outbox helper** (ADR-0029) — the optional `babelqueue.outbox` module ports the
|
|
16
|
+
PHP `BabelQueue\Outbox` helper to Python, removing the producer **dual write**: the message is
|
|
17
|
+
persisted **into your database, in the same transaction** as the business data (so it commits or
|
|
18
|
+
rolls back atomically with it), and a separate **relay** publishes the durable rows afterwards.
|
|
19
|
+
No distributed transaction; exactly-once *handoff* into the broker, then at-least-once on the wire
|
|
20
|
+
(the consumer dedupes on `meta.id` via the idempotency helper, the consumer-side mirror, ADR-0022).
|
|
21
|
+
The core stays **stdlib-only** (GR-7): `OutboxStore` is an abstract `typing.Protocol` the caller
|
|
22
|
+
binds to their own DB — the module ships only the in-memory `InMemoryOutboxStore` reference and
|
|
23
|
+
pulls in **no** DB driver. `Outbox.write(envelope)` encodes via the frozen `EnvelopeCodec` and
|
|
24
|
+
delegates to `OutboxStore.save` **inside the transaction the caller already opened** — it does not
|
|
25
|
+
begin/commit anything (the caller owns the transaction boundary). `OutboxRelay.flush()` publishes
|
|
26
|
+
one batch through the existing publish-only `Transport`, marking each row published **only after**
|
|
27
|
+
the transport accepts it, or failed (caught → `mark_failed`, row left pending, with a bounded
|
|
28
|
+
linear backoff via an injectable sleeper) so one poison row never blocks the batch;
|
|
29
|
+
`OutboxRelay.drain()` loops while a pass makes progress, with a safety ceiling. The relay
|
|
30
|
+
publishes the **stored bytes verbatim** — it never decodes, rebuilds or re-encodes the envelope —
|
|
31
|
+
so `trace_id` is preserved end-to-end and the body is byte-compatible across SDKs (GR-1/GR-4/GR-5).
|
|
32
|
+
Unit-tested without a broker (write stores the encoded envelope byte-identical; relay publishes via
|
|
33
|
+
a fake `Transport` + marks published; a raising publish → `mark_failed`, row still pending, batch
|
|
34
|
+
continues; `drain` loops to empty and stops on no-progress; backoff grows linearly and caps via the
|
|
35
|
+
injected sleeper). The envelope is unchanged (`schema_version: 1`); this is purely additive.
|
|
36
|
+
|
|
37
|
+
## [1.11.0] - 2026-06-21
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- **W3C `traceparent` span-context propagation** (OpenTelemetry v0.2, ADR-0028) — the optional
|
|
41
|
+
`babelqueue.otel` module now carries true cross-hop **span** parent-child linkage, not just
|
|
42
|
+
shared-`trace_id` correlation. On publish, `otel.publish` injects the active span context as a
|
|
43
|
+
W3C `traceparent` **transport header** (and still stamps `trace_id` for the v0.1 fallback); on
|
|
44
|
+
consume, `otel.wrap_handler` extracts it and starts the CONSUMER span as a true **child** of the
|
|
45
|
+
producer span. With no `traceparent` present it falls back to the v0.1 `trace_id`-derived parent,
|
|
46
|
+
so it is a strict, backward-compatible upgrade — no regression. The header rides **out of band**
|
|
47
|
+
via a new dependency-free core seam — `BabelQueue.publish_with_headers(urn, data, headers, …)`
|
|
48
|
+
(produce side) and `babelqueue.headers_from_context()` (consume side, surfaced by the runtime) —
|
|
49
|
+
so the wire envelope stays **frozen** (`schema_version: 1`, GR-1) and the core stays
|
|
50
|
+
zero-dependency (OTel remains the optional `[otel]` extra, GR-7). `traceparent` is carried on the
|
|
51
|
+
**in-memory** (reference), **Redis** (a transport-owned `__bq_frame` JSON frame with bare-value
|
|
52
|
+
back-compat, so cross-version queues interoperate; degrades to a bare publish in Laravel-compat
|
|
53
|
+
mode), **RabbitMQ** (native AMQP header table, beside the contract `x-*` headers) and **SQS**
|
|
54
|
+
(native `MessageAttributes`, beside the contract `bq-*` attributes) transports; where a transport
|
|
55
|
+
can't carry headers, propagation degrades cleanly to v0.1 `trace_id` correlation with no error.
|
|
56
|
+
A plain `publish` is byte-identical to before. Unit-tested without a broker (frame round-trip +
|
|
57
|
+
bare back-compat, header merge/extract per transport, and an in-memory producer→consumer
|
|
58
|
+
parent-child end-to-end with the OTel SDK's `InMemorySpanExporter`); broker-gated integration
|
|
59
|
+
tests assert a published `traceparent` arrives on the consumed message's headers beside the
|
|
60
|
+
unchanged body. The envelope is unchanged; this is purely additive. Ships as a MINOR.
|
|
61
|
+
|
|
12
62
|
## [1.6.0] - 2026-06-14
|
|
13
63
|
|
|
14
64
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.12.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
|
|
@@ -234,6 +234,83 @@ def on_created(data, meta): ...
|
|
|
234
234
|
python manage.py babelqueue_worker --queue orders # run the consumer
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
## Transactional outbox (optional)
|
|
238
|
+
|
|
239
|
+
The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
|
|
240
|
+
business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
|
|
241
|
+
message is persisted **into your database, in the same transaction** as the business data — so it
|
|
242
|
+
commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
|
|
243
|
+
afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
|
|
244
|
+
on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
|
|
245
|
+
|
|
246
|
+
The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
|
|
247
|
+
(the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
|
|
248
|
+
unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
|
|
249
|
+
rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
|
|
250
|
+
|
|
251
|
+
```python
|
|
252
|
+
from babelqueue import BabelQueue, EnvelopeCodec
|
|
253
|
+
from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
|
|
254
|
+
|
|
255
|
+
store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
|
|
256
|
+
outbox = Outbox(store)
|
|
257
|
+
|
|
258
|
+
# write side — YOU own the transaction boundary (this is the whole point):
|
|
259
|
+
with db.transaction(): # your own open transaction
|
|
260
|
+
db.insert_order(order) # the business write
|
|
261
|
+
envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
|
|
262
|
+
outbox.write(envelope) # same connection, same tx — both, or neither
|
|
263
|
+
|
|
264
|
+
# read/publish side — run on a short interval, after the business tx commits:
|
|
265
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
266
|
+
relay = OutboxRelay(app.transport, store)
|
|
267
|
+
relay.drain() # publish all pending rows; flush() does one batch
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
|
|
271
|
+
A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
|
|
272
|
+
marks a row published only **after** the transport accepts it; a publish that raises is recorded via
|
|
273
|
+
`mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
|
|
274
|
+
poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
|
|
275
|
+
ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
|
|
276
|
+
is the reference for tests and single-process demos (no real transaction).
|
|
277
|
+
|
|
278
|
+
## OpenTelemetry tracing (optional)
|
|
279
|
+
|
|
280
|
+
`pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
|
|
281
|
+
imports OpenTelemetry, so it stays zero-dependency. It emits a PRODUCER span per publish and a
|
|
282
|
+
CONSUMER span per handled message, correlated across every hop and SDK, at two layered levels:
|
|
283
|
+
|
|
284
|
+
- **`trace_id` correlation** (v0.1): the envelope's `trace_id` maps 1:1 to an OTel trace id, so
|
|
285
|
+
every hop that shares a `trace_id` shares one trace — with **zero** wire/transport change.
|
|
286
|
+
- **W3C `traceparent` span linkage** (v0.2): the producer also injects its active span context as
|
|
287
|
+
a `traceparent` **transport header** (beside the frozen envelope, never in it), so the consumer
|
|
288
|
+
starts its span as a true **child** of the producer span — real cross-hop parent-child linkage.
|
|
289
|
+
With no `traceparent` present it falls back to the v0.1 `trace_id` behaviour, so enabling it is a
|
|
290
|
+
strict, backward-compatible upgrade.
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
from opentelemetry import trace
|
|
294
|
+
from babelqueue import BabelQueue, otel
|
|
295
|
+
|
|
296
|
+
tracer = trace.get_tracer("orders")
|
|
297
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
298
|
+
|
|
299
|
+
# consumer: wrap_handler starts a CONSUMER span (child of the producer span when a
|
|
300
|
+
# traceparent rode along; else in the trace_id-derived trace)
|
|
301
|
+
app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
|
|
302
|
+
|
|
303
|
+
# producer: otel.publish starts a PRODUCER span and carries traceparent + trace_id
|
|
304
|
+
otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
The `traceparent` rides the out-of-band transport-header seam (`publish_with_headers` /
|
|
308
|
+
`headers_from_context`) — the same seam the replay-bypass marker uses — so the envelope stays
|
|
309
|
+
frozen (`schema_version: 1`). It is carried on the in-memory, Redis (a transport-owned JSON frame,
|
|
310
|
+
with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttributes`)
|
|
311
|
+
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
312
|
+
correlation with no error.
|
|
313
|
+
|
|
237
314
|
## What's here
|
|
238
315
|
|
|
239
316
|
The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
|
|
@@ -182,6 +182,83 @@ def on_created(data, meta): ...
|
|
|
182
182
|
python manage.py babelqueue_worker --queue orders # run the consumer
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
## Transactional outbox (optional)
|
|
186
|
+
|
|
187
|
+
The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
|
|
188
|
+
business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
|
|
189
|
+
message is persisted **into your database, in the same transaction** as the business data — so it
|
|
190
|
+
commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
|
|
191
|
+
afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
|
|
192
|
+
on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
|
|
193
|
+
|
|
194
|
+
The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
|
|
195
|
+
(the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
|
|
196
|
+
unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
|
|
197
|
+
rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from babelqueue import BabelQueue, EnvelopeCodec
|
|
201
|
+
from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
|
|
202
|
+
|
|
203
|
+
store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
|
|
204
|
+
outbox = Outbox(store)
|
|
205
|
+
|
|
206
|
+
# write side — YOU own the transaction boundary (this is the whole point):
|
|
207
|
+
with db.transaction(): # your own open transaction
|
|
208
|
+
db.insert_order(order) # the business write
|
|
209
|
+
envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
|
|
210
|
+
outbox.write(envelope) # same connection, same tx — both, or neither
|
|
211
|
+
|
|
212
|
+
# read/publish side — run on a short interval, after the business tx commits:
|
|
213
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
214
|
+
relay = OutboxRelay(app.transport, store)
|
|
215
|
+
relay.drain() # publish all pending rows; flush() does one batch
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
|
|
219
|
+
A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
|
|
220
|
+
marks a row published only **after** the transport accepts it; a publish that raises is recorded via
|
|
221
|
+
`mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
|
|
222
|
+
poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
|
|
223
|
+
ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
|
|
224
|
+
is the reference for tests and single-process demos (no real transaction).
|
|
225
|
+
|
|
226
|
+
## OpenTelemetry tracing (optional)
|
|
227
|
+
|
|
228
|
+
`pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
|
|
229
|
+
imports OpenTelemetry, so it stays zero-dependency. It emits a PRODUCER span per publish and a
|
|
230
|
+
CONSUMER span per handled message, correlated across every hop and SDK, at two layered levels:
|
|
231
|
+
|
|
232
|
+
- **`trace_id` correlation** (v0.1): the envelope's `trace_id` maps 1:1 to an OTel trace id, so
|
|
233
|
+
every hop that shares a `trace_id` shares one trace — with **zero** wire/transport change.
|
|
234
|
+
- **W3C `traceparent` span linkage** (v0.2): the producer also injects its active span context as
|
|
235
|
+
a `traceparent` **transport header** (beside the frozen envelope, never in it), so the consumer
|
|
236
|
+
starts its span as a true **child** of the producer span — real cross-hop parent-child linkage.
|
|
237
|
+
With no `traceparent` present it falls back to the v0.1 `trace_id` behaviour, so enabling it is a
|
|
238
|
+
strict, backward-compatible upgrade.
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
from opentelemetry import trace
|
|
242
|
+
from babelqueue import BabelQueue, otel
|
|
243
|
+
|
|
244
|
+
tracer = trace.get_tracer("orders")
|
|
245
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
246
|
+
|
|
247
|
+
# consumer: wrap_handler starts a CONSUMER span (child of the producer span when a
|
|
248
|
+
# traceparent rode along; else in the trace_id-derived trace)
|
|
249
|
+
app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
|
|
250
|
+
|
|
251
|
+
# producer: otel.publish starts a PRODUCER span and carries traceparent + trace_id
|
|
252
|
+
otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The `traceparent` rides the out-of-band transport-header seam (`publish_with_headers` /
|
|
256
|
+
`headers_from_context`) — the same seam the replay-bypass marker uses — so the envelope stays
|
|
257
|
+
frozen (`schema_version: 1`). It is carried on the in-memory, Redis (a transport-owned JSON frame,
|
|
258
|
+
with bare-value back-compat), RabbitMQ (AMQP header table) and SQS (`MessageAttributes`)
|
|
259
|
+
transports; where a transport can't carry it, propagation degrades cleanly to v0.1 `trace_id`
|
|
260
|
+
correlation with no error.
|
|
261
|
+
|
|
185
262
|
## What's here
|
|
186
263
|
|
|
187
264
|
The codec/contracts/dead-letter (zero-dep core), the `BabelQueue` runtime
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.12.0"
|
|
8
8
|
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -11,17 +11,26 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
-
from . import dead_letter, idempotency, redrive, replay
|
|
14
|
+
from . import dead_letter, headers, idempotency, outbox, redrive, replay
|
|
15
15
|
from .app import BabelQueue
|
|
16
16
|
from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
|
|
17
17
|
from .contracts import HasTraceId, PolyglotMessage
|
|
18
|
+
from .headers import headers_from_context
|
|
18
19
|
from .idempotency import IdempotencyStore, InMemoryStore
|
|
20
|
+
from .outbox import (
|
|
21
|
+
InMemoryOutboxStore,
|
|
22
|
+
Outbox,
|
|
23
|
+
OutboxRecord,
|
|
24
|
+
OutboxRelay,
|
|
25
|
+
OutboxRelayResult,
|
|
26
|
+
OutboxStore,
|
|
27
|
+
)
|
|
19
28
|
from .exceptions import BabelQueueError, UnknownUrnError
|
|
20
29
|
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
21
30
|
from .routing import UnknownUrnStrategy
|
|
22
31
|
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
|
|
23
32
|
|
|
24
|
-
__version__ = "1.
|
|
33
|
+
__version__ = "1.12.0"
|
|
25
34
|
|
|
26
35
|
__all__ = [
|
|
27
36
|
"BabelQueue",
|
|
@@ -38,11 +47,20 @@ __all__ = [
|
|
|
38
47
|
"BabelQueueError",
|
|
39
48
|
"UnknownUrnError",
|
|
40
49
|
"dead_letter",
|
|
50
|
+
"headers",
|
|
41
51
|
"idempotency",
|
|
52
|
+
"outbox",
|
|
53
|
+
"Outbox",
|
|
54
|
+
"OutboxStore",
|
|
55
|
+
"OutboxRecord",
|
|
56
|
+
"OutboxRelay",
|
|
57
|
+
"OutboxRelayResult",
|
|
58
|
+
"InMemoryOutboxStore",
|
|
42
59
|
"redrive",
|
|
43
60
|
"replay",
|
|
44
61
|
"is_replay",
|
|
45
62
|
"bypass_external_effects",
|
|
63
|
+
"headers_from_context",
|
|
46
64
|
"HEADER_REPLAY_BYPASS",
|
|
47
65
|
"IdempotencyStore",
|
|
48
66
|
"InMemoryStore",
|
|
@@ -25,9 +25,10 @@ from typing import Any, Callable, Dict, Mapping, Optional
|
|
|
25
25
|
from . import dead_letter
|
|
26
26
|
from .codec import EnvelopeCodec
|
|
27
27
|
from .exceptions import UnknownUrnError
|
|
28
|
+
from .headers import _headers_scope
|
|
28
29
|
from .replay import HEADER_REPLAY_BYPASS, _replay_scope
|
|
29
30
|
from .routing import UnknownUrnStrategy
|
|
30
|
-
from .transport import ReceivedMessage, Transport, make_transport
|
|
31
|
+
from .transport import HeaderPublisher, ReceivedMessage, Transport, make_transport
|
|
31
32
|
|
|
32
33
|
Handler = Callable[..., None]
|
|
33
34
|
|
|
@@ -70,6 +71,37 @@ class BabelQueue:
|
|
|
70
71
|
self.transport.publish(target, EnvelopeCodec.encode(envelope))
|
|
71
72
|
return envelope["meta"]["id"]
|
|
72
73
|
|
|
74
|
+
def publish_with_headers(
|
|
75
|
+
self,
|
|
76
|
+
urn: str,
|
|
77
|
+
data: Mapping[str, Any],
|
|
78
|
+
headers: Mapping[str, str],
|
|
79
|
+
*,
|
|
80
|
+
queue: Optional[str] = None,
|
|
81
|
+
trace_id: Optional[str] = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Publish a message together with out-of-band transport ``headers``; returns its id.
|
|
84
|
+
|
|
85
|
+
The headers ride **beside** the frozen envelope (GR-1) on the transport's per-message
|
|
86
|
+
metadata channel — e.g. a W3C ``traceparent`` for cross-hop span linkage (ADR-0028) —
|
|
87
|
+
never inside it. It is the produce-side counterpart of the headers the runtime surfaces
|
|
88
|
+
to a handler via :func:`~babelqueue.headers.headers_from_context`.
|
|
89
|
+
|
|
90
|
+
When the transport implements :class:`~babelqueue.transport.HeaderPublisher` and
|
|
91
|
+
``headers`` is non-empty, the headers are propagated; otherwise it transparently falls
|
|
92
|
+
back to a plain :meth:`publish` (the headers are dropped — no error, no regression),
|
|
93
|
+
exactly as :func:`~babelqueue.redrive.redrive` degrades. Passing empty headers is
|
|
94
|
+
equivalent to :meth:`publish`, so callers need not branch on transport capability.
|
|
95
|
+
"""
|
|
96
|
+
target = queue or self.queue
|
|
97
|
+
envelope = EnvelopeCodec.make(urn, data, queue=target, trace_id=trace_id)
|
|
98
|
+
body = EnvelopeCodec.encode(envelope)
|
|
99
|
+
if headers and isinstance(self.transport, HeaderPublisher):
|
|
100
|
+
self.transport.publish_with_headers(target, body, dict(headers))
|
|
101
|
+
else:
|
|
102
|
+
self.transport.publish(target, body)
|
|
103
|
+
return envelope["meta"]["id"]
|
|
104
|
+
|
|
73
105
|
# -- Register handlers --------------------------------------------------
|
|
74
106
|
|
|
75
107
|
def handler(self, urn: str) -> Callable[[Handler], Handler]:
|
|
@@ -116,8 +148,16 @@ class BabelQueue:
|
|
|
116
148
|
run = consume
|
|
117
149
|
|
|
118
150
|
def dispatch(self, received: ReceivedMessage) -> None:
|
|
119
|
-
"""Route one reserved message to its handler and acknowledge it.
|
|
120
|
-
|
|
151
|
+
"""Route one reserved message to its handler and acknowledge it.
|
|
152
|
+
|
|
153
|
+
The delivered message's out-of-band transport headers are surfaced onto the context for
|
|
154
|
+
the span of this dispatch (:func:`~babelqueue.headers.headers_from_context`), so a handler
|
|
155
|
+
or an optional wrapper (e.g. the ``otel`` module reading a W3C ``traceparent``, ADR-0028)
|
|
156
|
+
can read metadata that travels beside the frozen envelope (GR-1).
|
|
157
|
+
"""
|
|
158
|
+
with _headers_scope(received.headers), _replay_scope(
|
|
159
|
+
bool(received.headers.get(HEADER_REPLAY_BYPASS))
|
|
160
|
+
):
|
|
121
161
|
envelope = EnvelopeCodec.decode(received.body)
|
|
122
162
|
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
123
163
|
handler = self._handlers.get(urn) if urn else None
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Consume-side out-of-band transport headers (the Python mirror of Go's ``headers.go``).
|
|
2
|
+
|
|
3
|
+
The runtime surfaces a delivered message's :attr:`~babelqueue.transport.ReceivedMessage.headers`
|
|
4
|
+
onto a :class:`contextvars.ContextVar` for the span of one dispatch, so a handler — or an
|
|
5
|
+
optional wrapper such as the ``otel`` module — can read per-message metadata that travels
|
|
6
|
+
**beside** the frozen envelope (GR-1), never in it. It is the consume-side counterpart of
|
|
7
|
+
:class:`~babelqueue.transport.HeaderPublisher`.
|
|
8
|
+
|
|
9
|
+
This is the same out-of-band seam the replay-bypass marker rides (ADR-0027); ADR-0028's W3C
|
|
10
|
+
``traceparent`` (for cross-hop span parent-child linkage) is the second rider on it. The header
|
|
11
|
+
map is read-only — treat it as immutable. Adds only :mod:`contextvars` + a plain ``dict`` (no
|
|
12
|
+
dependency), exactly like :mod:`babelqueue.replay`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import contextvars
|
|
19
|
+
from typing import Dict, Iterator, Mapping, Optional
|
|
20
|
+
|
|
21
|
+
#: The delivered message's out-of-band transport headers, for the span of one dispatch.
|
|
22
|
+
#: Defaults to an empty mapping so :func:`headers_from_context` is always nil-safe.
|
|
23
|
+
_headers_var: contextvars.ContextVar[Mapping[str, str]] = contextvars.ContextVar(
|
|
24
|
+
"babelqueue_headers", default={}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def headers_from_context() -> Mapping[str, str]:
|
|
29
|
+
"""Return the out-of-band transport headers that arrived with the message currently being
|
|
30
|
+
handled, or an empty mapping when none were carried (or the transport surfaces none).
|
|
31
|
+
|
|
32
|
+
The returned mapping is read-only — do not mutate it. It is the consume-side counterpart of
|
|
33
|
+
:class:`~babelqueue.transport.HeaderPublisher`: a handler or an optional wrapper (e.g. the
|
|
34
|
+
``otel`` module's :func:`~babelqueue.otel.wrap_handler`) reads per-message metadata that
|
|
35
|
+
travels beside the frozen envelope, never in it (GR-1).
|
|
36
|
+
"""
|
|
37
|
+
return _headers_var.get()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextlib.contextmanager
|
|
41
|
+
def _headers_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
|
|
42
|
+
"""Internal: surface ``headers`` on the context for the span of one dispatch, then reset.
|
|
43
|
+
|
|
44
|
+
A nil/empty map is fine; reads stay nil-safe. The runtime calls this in
|
|
45
|
+
:meth:`~babelqueue.app.BabelQueue.dispatch` so wrappers can read the delivered headers.
|
|
46
|
+
"""
|
|
47
|
+
scoped: Mapping[str, str] = headers or {}
|
|
48
|
+
token = _headers_var.set(scoped)
|
|
49
|
+
try:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
_headers_var.reset(token)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def merge_headers(*sources: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
56
|
+
"""Combine header maps into a single ``dict[str, str]``, dropping blank keys and blank values.
|
|
57
|
+
|
|
58
|
+
Later sources win a key collision. Returns a fresh dict (callers may mutate it freely). Used
|
|
59
|
+
to merge an injected ``traceparent`` onto a transport's contract headers without clobbering
|
|
60
|
+
them — the contract keys are passed *last* so they win (mirrors the Go merge-not-clobber).
|
|
61
|
+
"""
|
|
62
|
+
out: Dict[str, str] = {}
|
|
63
|
+
for source in sources:
|
|
64
|
+
if not source:
|
|
65
|
+
continue
|
|
66
|
+
for key, value in source.items():
|
|
67
|
+
if not key or value is None or value == "":
|
|
68
|
+
continue
|
|
69
|
+
out[str(key)] = str(value)
|
|
70
|
+
return out
|