babelqueue 1.6.0__tar.gz → 1.8.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.6.0 → babelqueue-1.8.0}/.github/workflows/ci.yml +3 -3
- {babelqueue-1.6.0 → babelqueue-1.8.0}/PKG-INFO +3 -1
- {babelqueue-1.6.0 → babelqueue-1.8.0}/pyproject.toml +2 -1
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/__init__.py +5 -1
- babelqueue-1.8.0/src/babelqueue/exceptions.py +25 -0
- babelqueue-1.8.0/src/babelqueue/idempotency.py +95 -0
- babelqueue-1.8.0/src/babelqueue/otel.py +176 -0
- babelqueue-1.8.0/src/babelqueue/schema.py +261 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/manifest.json +23 -0
- babelqueue-1.8.0/tests/test_idempotency.py +89 -0
- babelqueue-1.8.0/tests/test_otel.py +131 -0
- babelqueue-1.8.0/tests/test_schema.py +145 -0
- babelqueue-1.8.0/tests/test_schema_conformance.py +33 -0
- babelqueue-1.6.0/src/babelqueue/exceptions.py +0 -11
- {babelqueue-1.6.0 → babelqueue-1.8.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/.gitignore +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/CHANGELOG.md +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/LICENSE +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/README.md +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_app.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_celery.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_codec.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_django.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.6.0 → babelqueue-1.8.0}/tests/test_sqs_transport.py +0 -0
|
@@ -27,7 +27,7 @@ jobs:
|
|
|
27
27
|
- name: Install (with Celery + Django adapters)
|
|
28
28
|
run: |
|
|
29
29
|
python -m pip install --upgrade pip
|
|
30
|
-
pip install -e ".[dev,celery,django]"
|
|
30
|
+
pip install -e ".[dev,celery,django,otel]" opentelemetry-sdk
|
|
31
31
|
|
|
32
32
|
- name: Run tests
|
|
33
33
|
run: pytest
|
|
@@ -44,7 +44,7 @@ jobs:
|
|
|
44
44
|
- name: Install (dev + all adapters for type context)
|
|
45
45
|
run: |
|
|
46
46
|
python -m pip install --upgrade pip
|
|
47
|
-
pip install -e ".[dev,celery,django,redis,amqp,sqs,azureservicebus,artemis]"
|
|
47
|
+
pip install -e ".[dev,celery,django,redis,amqp,sqs,azureservicebus,artemis,otel]" opentelemetry-sdk
|
|
48
48
|
- name: Ruff
|
|
49
49
|
run: ruff check src tests
|
|
50
50
|
- name: Mypy
|
|
@@ -89,7 +89,7 @@ jobs:
|
|
|
89
89
|
- name: Install (all adapters — full coverage with brokers)
|
|
90
90
|
run: |
|
|
91
91
|
python -m pip install --upgrade pip
|
|
92
|
-
pip install -e ".[redis,amqp,sqs,azureservicebus,artemis,celery,django,dev]"
|
|
92
|
+
pip install -e ".[redis,amqp,sqs,azureservicebus,artemis,celery,django,dev,otel]" opentelemetry-sdk
|
|
93
93
|
|
|
94
94
|
- name: Wait for ElasticMQ
|
|
95
95
|
run: |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.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
|
|
@@ -40,6 +40,8 @@ Provides-Extra: django
|
|
|
40
40
|
Requires-Dist: django>=4.2; extra == 'django'
|
|
41
41
|
Provides-Extra: kafka
|
|
42
42
|
Requires-Dist: confluent-kafka>=2.3; extra == 'kafka'
|
|
43
|
+
Provides-Extra: otel
|
|
44
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
|
|
43
45
|
Provides-Extra: pulsar
|
|
44
46
|
Requires-Dist: pulsar-client>=3.4; extra == 'pulsar'
|
|
45
47
|
Provides-Extra: redis
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.8.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"
|
|
@@ -36,6 +36,7 @@ azureservicebus = ["azure-servicebus>=7.11", "azure-identity>=1.15"]
|
|
|
36
36
|
pulsar = ["pulsar-client>=3.4"]
|
|
37
37
|
kafka = ["confluent-kafka>=2.3"]
|
|
38
38
|
artemis = ["python-qpid-proton>=0.39"]
|
|
39
|
+
otel = ["opentelemetry-api>=1.20"]
|
|
39
40
|
celery = ["celery>=5"]
|
|
40
41
|
django = ["django>=4.2"]
|
|
41
42
|
dev = ["pytest>=7", "pytest-cov>=4", "mypy>=1.8", "ruff>=0.5"]
|
|
@@ -11,10 +11,11 @@ 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
|
|
14
|
+
from . import dead_letter, idempotency
|
|
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 .idempotency import IdempotencyStore, InMemoryStore
|
|
18
19
|
from .exceptions import BabelQueueError, UnknownUrnError
|
|
19
20
|
from .routing import UnknownUrnStrategy
|
|
20
21
|
from .transport import InMemoryTransport, ReceivedMessage, Transport
|
|
@@ -35,5 +36,8 @@ __all__ = [
|
|
|
35
36
|
"BabelQueueError",
|
|
36
37
|
"UnknownUrnError",
|
|
37
38
|
"dead_letter",
|
|
39
|
+
"idempotency",
|
|
40
|
+
"IdempotencyStore",
|
|
41
|
+
"InMemoryStore",
|
|
38
42
|
"__version__",
|
|
39
43
|
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exception hierarchy for BabelQueue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BabelQueueError(Exception):
|
|
7
|
+
"""Base for every recoverable BabelQueue error (bad config, empty URN, ...)."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownUrnError(BabelQueueError):
|
|
11
|
+
"""A consumed message carries a URN with no mapped handler (strategy "fail")."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvalidPayloadError(BabelQueueError):
|
|
15
|
+
"""A message's ``data`` does not match the JSON Schema registered for its URN (ADR-0024).
|
|
16
|
+
|
|
17
|
+
Raised by the producer-side :func:`babelqueue.schema.validate` and the consumer-side
|
|
18
|
+
:func:`babelqueue.schema.wrap`; the latter lets the runtime redeliver (and eventually
|
|
19
|
+
dead-letter) a poison message.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, urn: str, violation: str) -> None:
|
|
23
|
+
super().__init__(f"data for {urn!r} does not match its URN schema: {violation}")
|
|
24
|
+
self.urn = urn
|
|
25
|
+
self.violation = violation
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Optional idempotency helper (ADR-0022): dedupe a consume handler on ``meta.id``.
|
|
2
|
+
|
|
3
|
+
The Python mirror of the PHP ``BabelQueue\\Idempotency`` and Go ``idempotency``
|
|
4
|
+
helpers. It wraps a handler so a message whose ``meta.id`` was already processed
|
|
5
|
+
successfully is skipped instead of run again, composing with the runtime's
|
|
6
|
+
ack-on-return / redeliver-on-raise contract::
|
|
7
|
+
|
|
8
|
+
from babelqueue import BabelQueue
|
|
9
|
+
from babelqueue.idempotency import InMemoryStore, wrap
|
|
10
|
+
|
|
11
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
12
|
+
store = InMemoryStore()
|
|
13
|
+
app.register("urn:babel:orders:created", wrap(store, on_order_created))
|
|
14
|
+
|
|
15
|
+
A previously-seen id returns early (the runtime acks it, so the broker stops
|
|
16
|
+
redelivering); a raising handler leaves the id unmarked so a redelivery runs it again
|
|
17
|
+
(retry / dead-letter still apply); a message with no usable ``meta.id`` runs unchanged.
|
|
18
|
+
This is "seen-set" post-success dedupe — not exactly-once and not in-flight concurrency
|
|
19
|
+
locking; a transactional / outbox mode is a documented future direction.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import functools
|
|
25
|
+
import threading
|
|
26
|
+
from typing import Any, Callable, Mapping, Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
Handler = Callable[..., None]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class IdempotencyStore(Protocol):
|
|
33
|
+
"""A record of message ids already processed, keyed on ``meta.id``."""
|
|
34
|
+
|
|
35
|
+
def seen(self, message_id: str) -> bool:
|
|
36
|
+
"""Whether this id has already been processed (remembered)."""
|
|
37
|
+
|
|
38
|
+
def remember(self, message_id: str) -> None:
|
|
39
|
+
"""Record this id as processed."""
|
|
40
|
+
|
|
41
|
+
def forget(self, message_id: str) -> None:
|
|
42
|
+
"""Drop an id from the store (manual eviction; a backend may also expire ids)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InMemoryStore:
|
|
46
|
+
"""Process-local, thread-safe :class:`IdempotencyStore`.
|
|
47
|
+
|
|
48
|
+
For tests and single-process consumers; it is not shared across workers and not
|
|
49
|
+
persistent — use a Redis- or database-backed store for production fleets.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._seen: set[str] = set()
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
def seen(self, message_id: str) -> bool:
|
|
57
|
+
with self._lock:
|
|
58
|
+
return message_id in self._seen
|
|
59
|
+
|
|
60
|
+
def remember(self, message_id: str) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
self._seen.add(message_id)
|
|
63
|
+
|
|
64
|
+
def forget(self, message_id: str) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._seen.discard(message_id)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def wrap(store: IdempotencyStore, handler: Handler) -> Handler:
|
|
70
|
+
"""Wrap ``handler`` so a message whose ``meta.id`` was already processed is skipped.
|
|
71
|
+
|
|
72
|
+
The returned callable keeps ``handler``'s signature (via :func:`functools.wraps`),
|
|
73
|
+
so the runtime's introspection still passes it the right number of positional args
|
|
74
|
+
(``data, meta`` or ``data, meta, envelope``).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
@functools.wraps(handler)
|
|
78
|
+
def wrapped(*args: Any) -> None:
|
|
79
|
+
meta = args[1] if len(args) > 1 and isinstance(args[1], Mapping) else {}
|
|
80
|
+
message_id = meta.get("id")
|
|
81
|
+
|
|
82
|
+
# No usable id → cannot dedupe; run the handler unchanged.
|
|
83
|
+
if not isinstance(message_id, str) or message_id == "":
|
|
84
|
+
handler(*args)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
# Already processed on an earlier delivery: return so the runtime acks it.
|
|
88
|
+
if store.seen(message_id):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# First success wins; a raise here leaves the id unmarked → retry/DLQ apply.
|
|
92
|
+
handler(*args)
|
|
93
|
+
store.remember(message_id)
|
|
94
|
+
|
|
95
|
+
return wrapped
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Optional OpenTelemetry tracing (ADR-0025): produce/consume spans correlated across hops.
|
|
2
|
+
|
|
3
|
+
The Python mirror of the Go ``babelqueue-go/otel`` module. It emits a CONSUMER span per
|
|
4
|
+
handled message and a PRODUCER span per publish, correlating them across every hop and SDK
|
|
5
|
+
through the envelope's ``trace_id`` — a UUID, which maps 1:1 to a 128-bit OTel trace id. The
|
|
6
|
+
wire envelope is untouched (GR-1) and the core never imports OpenTelemetry: this module is
|
|
7
|
+
only importable with the ``[otel]`` extra (``pip install babelqueue[otel]``), exactly like the
|
|
8
|
+
optional transport drivers.
|
|
9
|
+
|
|
10
|
+
from opentelemetry import trace
|
|
11
|
+
from babelqueue import BabelQueue, otel
|
|
12
|
+
|
|
13
|
+
tracer = trace.get_tracer("orders")
|
|
14
|
+
app = BabelQueue("redis://localhost:6379/0", queue="orders")
|
|
15
|
+
app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
|
|
16
|
+
# producer side:
|
|
17
|
+
otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
|
|
18
|
+
|
|
19
|
+
Every hop that shares a ``trace_id`` shares one OTel trace. Exact cross-hop *span*
|
|
20
|
+
parent-child linkage (W3C ``traceparent`` as a transport header) is a documented follow-up.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import inspect
|
|
27
|
+
from typing import Any, Callable, Mapping, Optional
|
|
28
|
+
|
|
29
|
+
from opentelemetry.context import Context
|
|
30
|
+
from opentelemetry.trace import (
|
|
31
|
+
NonRecordingSpan,
|
|
32
|
+
SpanContext,
|
|
33
|
+
SpanKind,
|
|
34
|
+
TraceFlags,
|
|
35
|
+
Tracer,
|
|
36
|
+
set_span_in_context,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
Handler = Callable[..., None]
|
|
40
|
+
|
|
41
|
+
_SYSTEM = "babelqueue"
|
|
42
|
+
_MASK_128 = (1 << 128) - 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def trace_id_of(trace_id: str) -> int:
|
|
46
|
+
"""Map an envelope ``trace_id`` to a deterministic 128-bit OTel trace id.
|
|
47
|
+
|
|
48
|
+
A UUID maps to its 16 raw bytes; any other string is hashed (SHA-256, first 16 bytes).
|
|
49
|
+
The result is never zero (OTel's invalid trace id). The inverse of :func:`uuid_of` for
|
|
50
|
+
the UUID case.
|
|
51
|
+
"""
|
|
52
|
+
raw = _uuid_bytes(trace_id)
|
|
53
|
+
if raw is not None:
|
|
54
|
+
n = int.from_bytes(raw, "big")
|
|
55
|
+
if n != 0:
|
|
56
|
+
return n
|
|
57
|
+
digest = hashlib.sha256(trace_id.encode("utf-8")).digest()[:16]
|
|
58
|
+
return int.from_bytes(digest, "big") or 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def uuid_of(trace_id_int: int) -> str:
|
|
62
|
+
"""Format a 128-bit OTel trace id as a canonical UUID string.
|
|
63
|
+
|
|
64
|
+
The form a producer stamps into the message's ``trace_id`` so a consumer can recover the
|
|
65
|
+
same trace id via :func:`trace_id_of`.
|
|
66
|
+
"""
|
|
67
|
+
h = format(trace_id_int & _MASK_128, "032x")
|
|
68
|
+
return f"{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _uuid_bytes(s: str) -> Optional[bytes]:
|
|
72
|
+
h = s.replace("-", "")
|
|
73
|
+
if len(h) != 32:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
return bytes.fromhex(h)
|
|
77
|
+
except ValueError:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _span_id_of(trace_id: str) -> int:
|
|
82
|
+
"""Derive a deterministic, non-zero 64-bit span id so the remote parent context is valid
|
|
83
|
+
(a span needs a valid parent to inherit a specific trace)."""
|
|
84
|
+
digest = hashlib.sha256(("babelqueue-span:" + trace_id).encode("utf-8")).digest()[:8]
|
|
85
|
+
return int.from_bytes(digest, "big") or 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parent_context(trace_id: str) -> Context:
|
|
89
|
+
sc = SpanContext(
|
|
90
|
+
trace_id=trace_id_of(trace_id),
|
|
91
|
+
span_id=_span_id_of(trace_id),
|
|
92
|
+
is_remote=True,
|
|
93
|
+
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
94
|
+
)
|
|
95
|
+
return set_span_in_context(NonRecordingSpan(sc))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _wants_envelope(fn: Handler) -> bool:
|
|
99
|
+
"""True if ``fn`` takes a 3rd positional arg (the full envelope) or ``*args``."""
|
|
100
|
+
try:
|
|
101
|
+
params = list(inspect.signature(fn).parameters.values())
|
|
102
|
+
except (TypeError, ValueError): # pragma: no cover - builtins / C callables
|
|
103
|
+
return False
|
|
104
|
+
positional = [
|
|
105
|
+
p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
106
|
+
]
|
|
107
|
+
return any(p.kind == p.VAR_POSITIONAL for p in params) or len(positional) >= 3
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def wrap_handler(tracer: Tracer, handler: Handler) -> Handler:
|
|
111
|
+
"""Wrap a consume handler to emit a CONSUMER span per message, in the OTel trace derived
|
|
112
|
+
from the envelope's ``trace_id``.
|
|
113
|
+
|
|
114
|
+
Register it like any handler: ``app.register(urn, wrap_handler(tracer, handler))``. The
|
|
115
|
+
wrapper's ``*args`` signature makes the runtime pass the full envelope (``data, meta,
|
|
116
|
+
envelope``), so it can read ``trace_id``/``job`` even when the inner handler only wants
|
|
117
|
+
``(data, meta)``. A raising handler records the exception on the span and re-raises, so the
|
|
118
|
+
runtime's retry / dead-letter path still applies.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def wrapped(*args: Any) -> None:
|
|
122
|
+
envelope = args[2] if len(args) > 2 and isinstance(args[2], Mapping) else {}
|
|
123
|
+
meta = args[1] if len(args) > 1 and isinstance(args[1], Mapping) else {}
|
|
124
|
+
trace_id = str(envelope.get("trace_id") or "")
|
|
125
|
+
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
126
|
+
|
|
127
|
+
attributes: dict[str, Any] = {
|
|
128
|
+
"messaging.system": _SYSTEM,
|
|
129
|
+
"messaging.operation": "process",
|
|
130
|
+
"messaging.destination.name": str(meta.get("queue") or ""),
|
|
131
|
+
"messaging.message.id": str(meta.get("id") or ""),
|
|
132
|
+
"messaging.message.conversation_id": trace_id,
|
|
133
|
+
"messaging.babelqueue.attempts": int(envelope.get("attempts", 0) or 0),
|
|
134
|
+
}
|
|
135
|
+
context = _parent_context(trace_id) if trace_id else None
|
|
136
|
+
|
|
137
|
+
with tracer.start_as_current_span(
|
|
138
|
+
"process " + urn,
|
|
139
|
+
context=context,
|
|
140
|
+
kind=SpanKind.CONSUMER,
|
|
141
|
+
attributes=attributes,
|
|
142
|
+
):
|
|
143
|
+
if _wants_envelope(handler):
|
|
144
|
+
handler(*args)
|
|
145
|
+
else:
|
|
146
|
+
handler(args[0], args[1])
|
|
147
|
+
|
|
148
|
+
return wrapped
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def publish(
|
|
152
|
+
tracer: Tracer,
|
|
153
|
+
app: Any,
|
|
154
|
+
urn: str,
|
|
155
|
+
data: Mapping[str, Any],
|
|
156
|
+
*,
|
|
157
|
+
queue: Optional[str] = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Publish via a PRODUCER span ``publish <urn>``, carrying the active trace's id into the
|
|
160
|
+
message's ``trace_id`` so the downstream consumer recovers the same trace.
|
|
161
|
+
|
|
162
|
+
Behaves like ``app.publish`` (returns the message id); ``app`` is any object exposing
|
|
163
|
+
``publish(urn, data, *, queue=None, trace_id=None) -> str``.
|
|
164
|
+
"""
|
|
165
|
+
attributes = {
|
|
166
|
+
"messaging.system": _SYSTEM,
|
|
167
|
+
"messaging.operation": "publish",
|
|
168
|
+
"messaging.destination.name": urn,
|
|
169
|
+
}
|
|
170
|
+
with tracer.start_as_current_span(
|
|
171
|
+
"publish " + urn, kind=SpanKind.PRODUCER, attributes=attributes
|
|
172
|
+
) as span:
|
|
173
|
+
trace_id = uuid_of(span.get_span_context().trace_id)
|
|
174
|
+
message_id = app.publish(urn, data, queue=queue, trace_id=trace_id)
|
|
175
|
+
span.set_attribute("messaging.message.id", message_id)
|
|
176
|
+
return message_id
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Optional per-URN payload schema validation (ADR-0024).
|
|
2
|
+
|
|
3
|
+
The Python mirror of the Go ``schema`` package and PHP ``BabelQueue\\Schema``. A
|
|
4
|
+
:class:`SchemaProvider` supplies a JSON Schema for a message URN — typically read from a
|
|
5
|
+
babelqueue-registry ``registry.json`` — and the message's ``data`` is validated against it.
|
|
6
|
+
It is opt-in: a URN with no registered schema is never validated.
|
|
7
|
+
|
|
8
|
+
- **Producer-side (recommended):** call :func:`validate` before publishing so invalid data
|
|
9
|
+
never enters the queue, or :func:`check` to branch without raising::
|
|
10
|
+
|
|
11
|
+
from babelqueue.schema import MapProvider, validate
|
|
12
|
+
|
|
13
|
+
provider = MapProvider.from_json({"urn:babel:orders:created": ORDERS_SCHEMA_JSON})
|
|
14
|
+
validate(provider, "urn:babel:orders:created", {"order_id": 7}) # raises on mismatch
|
|
15
|
+
|
|
16
|
+
- **Consumer-side (safety net):** wrap a handler with :func:`wrap`. Because a Python handler
|
|
17
|
+
receives ``data`` (and ``meta``, ``envelope``) positionally rather than a message object,
|
|
18
|
+
the URN is passed explicitly — usually the same URN you register under::
|
|
19
|
+
|
|
20
|
+
app.register(URN, wrap(provider, URN, on_order_created))
|
|
21
|
+
|
|
22
|
+
Invalid data raises :class:`~babelqueue.exceptions.InvalidPayloadError`, so the runtime
|
|
23
|
+
redelivers (and eventually dead-letters) the poison message; a URN with no schema runs the
|
|
24
|
+
handler unchanged.
|
|
25
|
+
|
|
26
|
+
The validator is a small subset of JSON Schema (draft-07) whose verdicts match the Go and
|
|
27
|
+
PHP validators and babelqueue-registry's ``compat`` linter: ``type``, ``required``,
|
|
28
|
+
``properties``, ``additionalProperties``, ``items``, ``enum``, ``const``, ``minLength``,
|
|
29
|
+
``minimum``. Unknown keywords are ignored. Zero dependencies (stdlib only).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import functools
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import threading
|
|
38
|
+
from typing import Any, Callable, Mapping, Optional, Protocol, runtime_checkable
|
|
39
|
+
|
|
40
|
+
from .exceptions import InvalidPayloadError
|
|
41
|
+
|
|
42
|
+
Handler = Callable[..., None]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class SchemaProvider(Protocol):
|
|
47
|
+
"""A source of per-URN ``data`` schemas, keyed on the message URN."""
|
|
48
|
+
|
|
49
|
+
def schema_for(self, urn: str) -> Optional[Mapping[str, Any]]:
|
|
50
|
+
"""The JSON Schema registered for ``urn``, or None when none is registered."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MapProvider:
|
|
54
|
+
"""In-memory :class:`SchemaProvider`, for tests and for embedding schemas in code."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, schemas: Mapping[str, Mapping[str, Any]]) -> None:
|
|
57
|
+
self._schemas: dict[str, Mapping[str, Any]] = dict(schemas)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_json(cls, raw: Mapping[str, str]) -> "MapProvider":
|
|
61
|
+
"""Build a provider from URN -> raw JSON Schema strings, decoding each."""
|
|
62
|
+
schemas: dict[str, Mapping[str, Any]] = {}
|
|
63
|
+
for urn, body in raw.items():
|
|
64
|
+
decoded = json.loads(body)
|
|
65
|
+
if not isinstance(decoded, dict):
|
|
66
|
+
raise ValueError(f"schema: invalid JSON schema for {urn!r}")
|
|
67
|
+
schemas[urn] = decoded
|
|
68
|
+
return cls(schemas)
|
|
69
|
+
|
|
70
|
+
def schema_for(self, urn: str) -> Optional[Mapping[str, Any]]:
|
|
71
|
+
return self._schemas.get(urn)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DirProvider:
|
|
75
|
+
"""Reads schemas from a babelqueue-registry manifest (``registry.json``): a list of
|
|
76
|
+
``{urn, schema}`` entries mapping each URN to a schema file for its ``data`` block. The
|
|
77
|
+
bridge that makes the registry's governed schemas enforceable at runtime. Schema files
|
|
78
|
+
are read and decoded lazily and cached (thread-safe). A URN absent from the manifest
|
|
79
|
+
returns None (skip); a URN whose schema file is missing raises (config/IO error)."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, manifest_path: str) -> None:
|
|
82
|
+
with open(manifest_path, "r", encoding="utf-8") as fh:
|
|
83
|
+
manifest = json.load(fh)
|
|
84
|
+
if not isinstance(manifest, dict):
|
|
85
|
+
raise ValueError(f"schema: invalid registry manifest {manifest_path!r}")
|
|
86
|
+
|
|
87
|
+
self._dir = os.path.dirname(manifest_path)
|
|
88
|
+
self._files: dict[str, str] = {}
|
|
89
|
+
self._cache: dict[str, Mapping[str, Any]] = {}
|
|
90
|
+
self._lock = threading.Lock()
|
|
91
|
+
|
|
92
|
+
entries = manifest.get("schemas") or []
|
|
93
|
+
if isinstance(entries, list):
|
|
94
|
+
for entry in entries:
|
|
95
|
+
if not isinstance(entry, Mapping):
|
|
96
|
+
continue
|
|
97
|
+
urn = entry.get("urn")
|
|
98
|
+
file = entry.get("schema")
|
|
99
|
+
if isinstance(urn, str) and urn and isinstance(file, str) and file:
|
|
100
|
+
self._files[urn] = file
|
|
101
|
+
|
|
102
|
+
def schema_for(self, urn: str) -> Optional[Mapping[str, Any]]:
|
|
103
|
+
with self._lock:
|
|
104
|
+
cached = self._cache.get(urn)
|
|
105
|
+
if cached is not None:
|
|
106
|
+
return cached
|
|
107
|
+
file = self._files.get(urn)
|
|
108
|
+
if file is None:
|
|
109
|
+
return None
|
|
110
|
+
path = file if os.path.isabs(file) else os.path.join(self._dir, file)
|
|
111
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
112
|
+
decoded = json.load(fh)
|
|
113
|
+
if not isinstance(decoded, dict):
|
|
114
|
+
raise ValueError(f"schema: invalid schema for {urn!r} ({file})")
|
|
115
|
+
self._cache[urn] = decoded
|
|
116
|
+
return decoded
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check(provider: SchemaProvider, urn: str, data: Mapping[str, Any]) -> Optional[str]:
|
|
120
|
+
"""The first ``data`` violation for ``(urn, data)``, or None when it is valid or when no
|
|
121
|
+
schema is registered for the URN (opt-in). Non-raising; for producer-side branching."""
|
|
122
|
+
schema = provider.schema_for(urn)
|
|
123
|
+
if schema is None:
|
|
124
|
+
return None
|
|
125
|
+
return validate_schema(schema, dict(data))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def validate(provider: SchemaProvider, urn: str, data: Mapping[str, Any]) -> None:
|
|
129
|
+
"""Validate ``(urn, data)`` against its registered schema, raising otherwise. The
|
|
130
|
+
producer-side guard; call it before publishing.
|
|
131
|
+
|
|
132
|
+
:raises InvalidPayloadError: when the data does not match the URN's schema.
|
|
133
|
+
"""
|
|
134
|
+
violation = check(provider, urn, data)
|
|
135
|
+
if violation is not None:
|
|
136
|
+
raise InvalidPayloadError(urn, violation)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def wrap(provider: SchemaProvider, urn: str, handler: Handler) -> Handler:
|
|
140
|
+
"""Wrap a consume handler so each message's ``data`` is validated against ``urn``'s
|
|
141
|
+
schema before the handler runs. The returned callable keeps ``handler``'s signature (via
|
|
142
|
+
:func:`functools.wraps`), so the runtime still passes it the right number of positional
|
|
143
|
+
args (``data, meta`` or ``data, meta, envelope``)."""
|
|
144
|
+
|
|
145
|
+
@functools.wraps(handler)
|
|
146
|
+
def wrapped(*args: Any) -> None:
|
|
147
|
+
data = args[0] if args and isinstance(args[0], Mapping) else {}
|
|
148
|
+
validate(provider, urn, data)
|
|
149
|
+
handler(*args)
|
|
150
|
+
|
|
151
|
+
return wrapped
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_schema(schema: Mapping[str, Any], value: Any, path: str = "") -> Optional[str]:
|
|
155
|
+
"""The first violation of ``value`` against a (subset) JSON Schema node, or None."""
|
|
156
|
+
if "const" in schema and not _equal(value, schema["const"]):
|
|
157
|
+
return _violation(path, "wrong_const")
|
|
158
|
+
|
|
159
|
+
enum = schema.get("enum")
|
|
160
|
+
if isinstance(enum, list) and not any(_equal(value, item) for item in enum):
|
|
161
|
+
return _violation(path, "not_in_enum")
|
|
162
|
+
|
|
163
|
+
typ = schema.get("type")
|
|
164
|
+
if typ == "object":
|
|
165
|
+
return _check_object(schema, value, path)
|
|
166
|
+
if typ == "array":
|
|
167
|
+
return _check_array(schema, value, path)
|
|
168
|
+
if typ == "string":
|
|
169
|
+
if not isinstance(value, str):
|
|
170
|
+
return _violation(path, "not_a_string")
|
|
171
|
+
min_len = schema.get("minLength")
|
|
172
|
+
if isinstance(min_len, (int, float)) and len(value) < int(min_len):
|
|
173
|
+
return _violation(path, "below_min_length")
|
|
174
|
+
return None
|
|
175
|
+
if typ == "integer":
|
|
176
|
+
if not _is_integer(value):
|
|
177
|
+
return _violation(path, "not_an_integer")
|
|
178
|
+
return _check_minimum(schema, value, path)
|
|
179
|
+
if typ == "number":
|
|
180
|
+
if not _is_number(value):
|
|
181
|
+
return _violation(path, "not_a_number")
|
|
182
|
+
return _check_minimum(schema, value, path)
|
|
183
|
+
if typ == "boolean":
|
|
184
|
+
return None if isinstance(value, bool) else _violation(path, "not_a_boolean")
|
|
185
|
+
if typ == "null":
|
|
186
|
+
return None if value is None else _violation(path, "not_null")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _check_object(schema: Mapping[str, Any], value: Any, path: str) -> Optional[str]:
|
|
191
|
+
if not isinstance(value, Mapping):
|
|
192
|
+
return _violation(path, "not_an_object")
|
|
193
|
+
|
|
194
|
+
required = schema.get("required")
|
|
195
|
+
if isinstance(required, list):
|
|
196
|
+
for key in required:
|
|
197
|
+
if isinstance(key, str) and key not in value:
|
|
198
|
+
return _violation(_join(path, key), "missing_required")
|
|
199
|
+
|
|
200
|
+
properties = schema.get("properties")
|
|
201
|
+
properties = properties if isinstance(properties, Mapping) else {}
|
|
202
|
+
additional_allowed = schema.get("additionalProperties") is not False
|
|
203
|
+
|
|
204
|
+
for key, item in value.items():
|
|
205
|
+
name = str(key)
|
|
206
|
+
prop = properties.get(name)
|
|
207
|
+
if isinstance(prop, Mapping):
|
|
208
|
+
violation = validate_schema(prop, item, _join(path, name))
|
|
209
|
+
if violation is not None:
|
|
210
|
+
return violation
|
|
211
|
+
continue
|
|
212
|
+
if not additional_allowed:
|
|
213
|
+
return _violation(_join(path, name), "additional_not_allowed")
|
|
214
|
+
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_array(schema: Mapping[str, Any], value: Any, path: str) -> Optional[str]:
|
|
219
|
+
if not isinstance(value, list):
|
|
220
|
+
return _violation(path, "not_an_array")
|
|
221
|
+
items = schema.get("items")
|
|
222
|
+
if not isinstance(items, Mapping):
|
|
223
|
+
return None
|
|
224
|
+
for i, item in enumerate(value):
|
|
225
|
+
violation = validate_schema(items, item, f"{path}[{i}]")
|
|
226
|
+
if violation is not None:
|
|
227
|
+
return violation
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _check_minimum(schema: Mapping[str, Any], value: Any, path: str) -> Optional[str]:
|
|
232
|
+
minimum = schema.get("minimum")
|
|
233
|
+
if isinstance(minimum, (int, float)) and not isinstance(minimum, bool) and float(value) < float(minimum):
|
|
234
|
+
return _violation(path, "below_minimum")
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _is_integer(value: Any) -> bool:
|
|
239
|
+
if isinstance(value, bool):
|
|
240
|
+
return False
|
|
241
|
+
if isinstance(value, int):
|
|
242
|
+
return True
|
|
243
|
+
return isinstance(value, float) and value.is_integer()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _is_number(value: Any) -> bool:
|
|
247
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _equal(a: Any, b: Any) -> bool:
|
|
251
|
+
# Type-aware equality so True != 1 and an integer const never matches a float value,
|
|
252
|
+
# matching the strict comparisons in the Go and PHP validators.
|
|
253
|
+
return type(a) is type(b) and a == b
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _violation(path: str, reason: str) -> str:
|
|
257
|
+
return f"{path or '<root>'}: {reason}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _join(path: str, key: str) -> str:
|
|
261
|
+
return key if path == "" else f"{path}.{key}"
|
|
@@ -226,5 +226,28 @@
|
|
|
226
226
|
"x-attempts": 0
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
|
+
},
|
|
230
|
+
"payload_schema": {
|
|
231
|
+
"description": "Per-URN data schema validation (ADR-0024). Each case validates `data` against `schema`; every SDK's optional payload validator (Go schema, PHP BabelQueue\\Schema, Python babelqueue.schema) MUST agree on `valid`. The wire envelope stays frozen — this governs the data block only, and is opt-in (consumers/producers without a registered schema skip it).",
|
|
232
|
+
"schema": {
|
|
233
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
234
|
+
"type": "object",
|
|
235
|
+
"required": ["order_id"],
|
|
236
|
+
"properties": {
|
|
237
|
+
"order_id": { "type": "integer", "minimum": 1 },
|
|
238
|
+
"amount": { "type": "number", "minimum": 0 },
|
|
239
|
+
"currency": { "enum": ["USD", "EUR", "TRY"] }
|
|
240
|
+
},
|
|
241
|
+
"additionalProperties": false
|
|
242
|
+
},
|
|
243
|
+
"cases": [
|
|
244
|
+
{ "name": "valid-minimal", "valid": true, "data": { "order_id": 1042 } },
|
|
245
|
+
{ "name": "valid-full", "valid": true, "data": { "order_id": 1042, "amount": 99.9, "currency": "USD" } },
|
|
246
|
+
{ "name": "invalid-missing-required", "valid": false, "data": { "amount": 10 } },
|
|
247
|
+
{ "name": "invalid-wrong-type", "valid": false, "data": { "order_id": "x" } },
|
|
248
|
+
{ "name": "invalid-additional-property", "valid": false, "data": { "order_id": 1, "extra": true } },
|
|
249
|
+
{ "name": "invalid-enum", "valid": false, "data": { "order_id": 1, "currency": "GBP" } },
|
|
250
|
+
{ "name": "invalid-below-minimum", "valid": false, "data": { "order_id": 0 } }
|
|
251
|
+
]
|
|
229
252
|
}
|
|
230
253
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
from babelqueue.app import _handler_wants_envelope
|
|
7
|
+
from babelqueue.idempotency import InMemoryStore, wrap
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IdempotencyTest(unittest.TestCase):
|
|
11
|
+
def test_runs_and_remembers_on_first_delivery(self) -> None:
|
|
12
|
+
store = InMemoryStore()
|
|
13
|
+
calls: List[str] = []
|
|
14
|
+
|
|
15
|
+
def handler(data: Dict[str, Any], meta: Dict[str, Any]) -> None:
|
|
16
|
+
calls.append(meta["id"])
|
|
17
|
+
|
|
18
|
+
wrapped = wrap(store, handler)
|
|
19
|
+
wrapped({}, {"id": "m1"})
|
|
20
|
+
|
|
21
|
+
self.assertEqual(calls, ["m1"])
|
|
22
|
+
self.assertTrue(store.seen("m1"))
|
|
23
|
+
|
|
24
|
+
def test_skips_redelivery_of_same_id(self) -> None:
|
|
25
|
+
store = InMemoryStore()
|
|
26
|
+
calls: List[int] = []
|
|
27
|
+
|
|
28
|
+
wrapped = wrap(store, lambda data, meta: calls.append(1))
|
|
29
|
+
wrapped({}, {"id": "m1"})
|
|
30
|
+
wrapped({}, {"id": "m1"}) # redelivery → skipped
|
|
31
|
+
|
|
32
|
+
self.assertEqual(len(calls), 1)
|
|
33
|
+
|
|
34
|
+
def test_runs_again_for_a_different_id(self) -> None:
|
|
35
|
+
store = InMemoryStore()
|
|
36
|
+
calls: List[int] = []
|
|
37
|
+
|
|
38
|
+
wrapped = wrap(store, lambda data, meta: calls.append(1))
|
|
39
|
+
wrapped({}, {"id": "m1"})
|
|
40
|
+
wrapped({}, {"id": "m2"})
|
|
41
|
+
|
|
42
|
+
self.assertEqual(len(calls), 2)
|
|
43
|
+
|
|
44
|
+
def test_does_not_remember_when_handler_raises(self) -> None:
|
|
45
|
+
store = InMemoryStore()
|
|
46
|
+
calls: List[int] = []
|
|
47
|
+
|
|
48
|
+
def boom(data: Dict[str, Any], meta: Dict[str, Any]) -> None:
|
|
49
|
+
calls.append(1)
|
|
50
|
+
raise RuntimeError("boom")
|
|
51
|
+
|
|
52
|
+
wrapped = wrap(store, boom)
|
|
53
|
+
with self.assertRaises(RuntimeError):
|
|
54
|
+
wrapped({}, {"id": "m1"})
|
|
55
|
+
self.assertFalse(store.seen("m1"))
|
|
56
|
+
|
|
57
|
+
# A redelivery runs the handler again — retry works.
|
|
58
|
+
with self.assertRaises(RuntimeError):
|
|
59
|
+
wrapped({}, {"id": "m1"})
|
|
60
|
+
self.assertEqual(len(calls), 2)
|
|
61
|
+
|
|
62
|
+
def test_runs_when_no_usable_id(self) -> None:
|
|
63
|
+
store = InMemoryStore()
|
|
64
|
+
calls: List[int] = []
|
|
65
|
+
|
|
66
|
+
wrapped = wrap(store, lambda data, meta: calls.append(1))
|
|
67
|
+
wrapped({}, {"id": ""}) # empty id → cannot dedupe → runs
|
|
68
|
+
wrapped({}, {}) # no id at all → runs
|
|
69
|
+
|
|
70
|
+
self.assertEqual(len(calls), 2)
|
|
71
|
+
|
|
72
|
+
def test_preserves_handler_arity_for_the_runtime(self) -> None:
|
|
73
|
+
# functools.wraps keeps inspect.signature transparent, so the runtime still
|
|
74
|
+
# passes the wrapped handler the right number of positional args.
|
|
75
|
+
store = InMemoryStore()
|
|
76
|
+
self.assertTrue(_handler_wants_envelope(wrap(store, lambda d, m, e: None)))
|
|
77
|
+
self.assertFalse(_handler_wants_envelope(wrap(store, lambda d, m: None)))
|
|
78
|
+
|
|
79
|
+
def test_forget_removes_a_remembered_id(self) -> None:
|
|
80
|
+
store = InMemoryStore()
|
|
81
|
+
store.remember("m1")
|
|
82
|
+
self.assertTrue(store.seen("m1"))
|
|
83
|
+
|
|
84
|
+
store.forget("m1")
|
|
85
|
+
self.assertFalse(store.seen("m1"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
unittest.main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tests for the optional OpenTelemetry module (ADR-0025).
|
|
2
|
+
|
|
3
|
+
Skipped when OpenTelemetry is not installed (the ``[otel]`` extra); CI installs it so these
|
|
4
|
+
run and count toward coverage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
pytest.importorskip("opentelemetry")
|
|
10
|
+
|
|
11
|
+
from opentelemetry.sdk.trace import TracerProvider # noqa: E402
|
|
12
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: E402
|
|
13
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402
|
|
14
|
+
InMemorySpanExporter,
|
|
15
|
+
)
|
|
16
|
+
from opentelemetry.trace import SpanKind # noqa: E402
|
|
17
|
+
|
|
18
|
+
from babelqueue import otel # noqa: E402
|
|
19
|
+
|
|
20
|
+
_TRACE_ID = "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _recorder():
|
|
24
|
+
exporter = InMemorySpanExporter()
|
|
25
|
+
provider = TracerProvider()
|
|
26
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
27
|
+
return provider.get_tracer("test"), exporter
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _envelope(trace_id=_TRACE_ID, attempts=0):
|
|
31
|
+
return {
|
|
32
|
+
"job": "urn:babel:orders:created",
|
|
33
|
+
"trace_id": trace_id,
|
|
34
|
+
"data": {"order_id": 1},
|
|
35
|
+
"meta": {"id": "m1", "queue": "orders"},
|
|
36
|
+
"attempts": attempts,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FakeApp:
|
|
41
|
+
"""An object exposing publish(urn, data, *, queue, trace_id) -> str, recording calls."""
|
|
42
|
+
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.calls = []
|
|
45
|
+
|
|
46
|
+
def publish(self, urn, data, *, queue=None, trace_id=None):
|
|
47
|
+
self.calls.append({"urn": urn, "data": data, "queue": queue, "trace_id": trace_id})
|
|
48
|
+
return "msg-123"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_trace_id_round_trip():
|
|
52
|
+
tid = otel.trace_id_of(_TRACE_ID)
|
|
53
|
+
assert tid != 0
|
|
54
|
+
assert otel.uuid_of(tid) == _TRACE_ID
|
|
55
|
+
# a non-uuid trace_id maps deterministically to a valid, distinct trace id
|
|
56
|
+
assert otel.trace_id_of("not-a-uuid") == otel.trace_id_of("not-a-uuid")
|
|
57
|
+
assert otel.trace_id_of("not-a-uuid") != 0
|
|
58
|
+
assert otel.trace_id_of("not-a-uuid") != tid
|
|
59
|
+
# 32 chars but not hex → not a UUID, so it is hashed
|
|
60
|
+
assert otel.trace_id_of("z" * 32) != 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_wrap_handler_span_in_trace_with_attrs():
|
|
64
|
+
tracer, exporter = _recorder()
|
|
65
|
+
seen = {}
|
|
66
|
+
|
|
67
|
+
def handler(data, meta, envelope):
|
|
68
|
+
seen["called"] = True
|
|
69
|
+
|
|
70
|
+
env = _envelope()
|
|
71
|
+
otel.wrap_handler(tracer, handler)(env["data"], env["meta"], env)
|
|
72
|
+
|
|
73
|
+
assert seen.get("called")
|
|
74
|
+
spans = exporter.get_finished_spans()
|
|
75
|
+
assert len(spans) == 1
|
|
76
|
+
span = spans[0]
|
|
77
|
+
assert span.name == "process urn:babel:orders:created"
|
|
78
|
+
assert span.kind == SpanKind.CONSUMER
|
|
79
|
+
assert span.context.trace_id == otel.trace_id_of(_TRACE_ID)
|
|
80
|
+
assert span.attributes["messaging.message.conversation_id"] == _TRACE_ID
|
|
81
|
+
assert span.attributes["messaging.message.id"] == "m1"
|
|
82
|
+
assert span.attributes["messaging.destination.name"] == "orders"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_wrap_handler_two_arg_inner():
|
|
86
|
+
tracer, exporter = _recorder()
|
|
87
|
+
got = {}
|
|
88
|
+
|
|
89
|
+
def handler(data, meta): # only two positional args
|
|
90
|
+
got["data"] = data
|
|
91
|
+
|
|
92
|
+
env = _envelope()
|
|
93
|
+
# the runtime passes (data, meta, envelope); the wrapper forwards only what the inner wants
|
|
94
|
+
otel.wrap_handler(tracer, handler)(env["data"], env["meta"], env)
|
|
95
|
+
|
|
96
|
+
assert got["data"] == env["data"]
|
|
97
|
+
assert len(exporter.get_finished_spans()) == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_wrap_handler_records_error():
|
|
101
|
+
tracer, exporter = _recorder()
|
|
102
|
+
|
|
103
|
+
def handler(data, meta, envelope):
|
|
104
|
+
raise ValueError("boom")
|
|
105
|
+
|
|
106
|
+
env = _envelope()
|
|
107
|
+
with pytest.raises(ValueError):
|
|
108
|
+
otel.wrap_handler(tracer, handler)(env["data"], env["meta"], env)
|
|
109
|
+
|
|
110
|
+
span = exporter.get_finished_spans()[0]
|
|
111
|
+
assert span.status.status_code.name == "ERROR"
|
|
112
|
+
assert len(span.events) >= 1 # the recorded exception
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_publish_stamps_trace_id_from_span():
|
|
116
|
+
tracer, exporter = _recorder()
|
|
117
|
+
app = FakeApp()
|
|
118
|
+
|
|
119
|
+
message_id = otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 7})
|
|
120
|
+
|
|
121
|
+
assert message_id == "msg-123"
|
|
122
|
+
spans = exporter.get_finished_spans()
|
|
123
|
+
assert len(spans) == 1
|
|
124
|
+
span = spans[0]
|
|
125
|
+
assert span.kind == SpanKind.PRODUCER
|
|
126
|
+
assert span.attributes["messaging.message.id"] == "msg-123"
|
|
127
|
+
|
|
128
|
+
stamped = app.calls[0]["trace_id"]
|
|
129
|
+
# the published trace_id encodes the producer span's trace, so a consumer recovers it
|
|
130
|
+
assert stamped == otel.uuid_of(span.context.trace_id)
|
|
131
|
+
assert otel.trace_id_of(stamped) == span.context.trace_id
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from babelqueue.exceptions import InvalidPayloadError
|
|
10
|
+
from babelqueue.schema import (
|
|
11
|
+
DirProvider,
|
|
12
|
+
MapProvider,
|
|
13
|
+
check,
|
|
14
|
+
validate,
|
|
15
|
+
validate_schema,
|
|
16
|
+
wrap,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ORDERS = (
|
|
20
|
+
'{"type":"object","required":["order_id"],'
|
|
21
|
+
'"properties":{"order_id":{"type":"integer"}},"additionalProperties":false}'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidatorTest(unittest.TestCase):
|
|
26
|
+
def test_object_required_types_and_additional(self) -> None:
|
|
27
|
+
schema = json.loads(
|
|
28
|
+
'{"type":"object","required":["order_id"],'
|
|
29
|
+
'"properties":{"order_id":{"type":"integer"},"note":{"type":"string","minLength":1}},'
|
|
30
|
+
'"additionalProperties":false}'
|
|
31
|
+
)
|
|
32
|
+
self.assertIsNone(validate_schema(schema, {"order_id": 7}))
|
|
33
|
+
self.assertIsNotNone(validate_schema(schema, {}))
|
|
34
|
+
self.assertIsNotNone(validate_schema(schema, {"order_id": "x"}))
|
|
35
|
+
self.assertIsNotNone(validate_schema(schema, {"order_id": 7, "extra": 1}))
|
|
36
|
+
self.assertIsNotNone(validate_schema(schema, {"order_id": 7, "note": ""}))
|
|
37
|
+
|
|
38
|
+
def test_enum_minimum_and_array_items(self) -> None:
|
|
39
|
+
schema = json.loads(
|
|
40
|
+
'{"type":"object","properties":{"status":{"enum":["new","paid"]},'
|
|
41
|
+
'"qty":{"type":"integer","minimum":1},'
|
|
42
|
+
'"tags":{"type":"array","items":{"type":"string"}}}}'
|
|
43
|
+
)
|
|
44
|
+
self.assertIsNone(validate_schema(schema, {"status": "paid", "qty": 2, "tags": ["a", "b"]}))
|
|
45
|
+
self.assertIsNotNone(validate_schema(schema, {"status": "cancelled"}))
|
|
46
|
+
self.assertIsNotNone(validate_schema(schema, {"qty": 0}))
|
|
47
|
+
self.assertIsNotNone(validate_schema(schema, {"tags": ["a", 1]}))
|
|
48
|
+
|
|
49
|
+
def test_scalar_types(self) -> None:
|
|
50
|
+
cases = [
|
|
51
|
+
('{"type":"boolean"}', True, True),
|
|
52
|
+
('{"type":"boolean"}', "x", False),
|
|
53
|
+
('{"type":"null"}', None, True),
|
|
54
|
+
('{"type":"null"}', 1, False),
|
|
55
|
+
('{"type":"number","minimum":0.5}', 0.6, True),
|
|
56
|
+
('{"type":"number","minimum":0.5}', 0.4, False),
|
|
57
|
+
('{"type":"number"}', "x", False),
|
|
58
|
+
('{"type":"string"}', 5, False),
|
|
59
|
+
('{"type":"integer"}', 1.0, True),
|
|
60
|
+
('{"type":"integer"}', 1.5, False),
|
|
61
|
+
('{"type":"integer"}', True, False), # bool is not an integer
|
|
62
|
+
('{"const":"v1"}', "v1", True),
|
|
63
|
+
('{"const":"v1"}', "v2", False),
|
|
64
|
+
]
|
|
65
|
+
for src, value, valid in cases:
|
|
66
|
+
schema = json.loads(src)
|
|
67
|
+
violation = validate_schema(schema, value)
|
|
68
|
+
self.assertEqual(valid, violation is None, f"{src} / {value!r}: {violation}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProviderTest(unittest.TestCase):
|
|
72
|
+
def test_map_provider_from_json(self) -> None:
|
|
73
|
+
provider = MapProvider.from_json({"urn:babel:orders:created": ORDERS})
|
|
74
|
+
self.assertIsNotNone(provider.schema_for("urn:babel:orders:created"))
|
|
75
|
+
self.assertIsNone(provider.schema_for("urn:babel:unknown"))
|
|
76
|
+
|
|
77
|
+
def test_map_provider_invalid_json(self) -> None:
|
|
78
|
+
with self.assertRaises(ValueError):
|
|
79
|
+
MapProvider.from_json({"u": "not json"})
|
|
80
|
+
|
|
81
|
+
def test_dir_provider_reads_registry_lazily(self) -> None:
|
|
82
|
+
with tempfile.TemporaryDirectory() as d:
|
|
83
|
+
os.makedirs(os.path.join(d, "schemas"))
|
|
84
|
+
with open(os.path.join(d, "schemas", "orders.json"), "w", encoding="utf-8") as fh:
|
|
85
|
+
fh.write('{"type":"object","required":["order_id"],"properties":{"order_id":{"type":"integer"}}}')
|
|
86
|
+
with open(os.path.join(d, "registry.json"), "w", encoding="utf-8") as fh:
|
|
87
|
+
fh.write('{"schemas":[{"urn":"urn:babel:orders:created","schema":"schemas/orders.json"},{"urn":"","schema":"x"}]}')
|
|
88
|
+
|
|
89
|
+
provider = DirProvider(os.path.join(d, "registry.json"))
|
|
90
|
+
for _ in range(2): # the second call hits the cache
|
|
91
|
+
self.assertIsNotNone(provider.schema_for("urn:babel:orders:created"))
|
|
92
|
+
self.assertIsNone(provider.schema_for("urn:babel:unknown"))
|
|
93
|
+
|
|
94
|
+
def test_dir_provider_missing_manifest(self) -> None:
|
|
95
|
+
with self.assertRaises(OSError):
|
|
96
|
+
DirProvider(os.path.join(tempfile.gettempdir(), "nope_registry_xyz.json"))
|
|
97
|
+
|
|
98
|
+
def test_dir_provider_missing_schema_file(self) -> None:
|
|
99
|
+
with tempfile.TemporaryDirectory() as d:
|
|
100
|
+
with open(os.path.join(d, "registry.json"), "w", encoding="utf-8") as fh:
|
|
101
|
+
fh.write('{"schemas":[{"urn":"u","schema":"missing.json"}]}')
|
|
102
|
+
provider = DirProvider(os.path.join(d, "registry.json"))
|
|
103
|
+
with self.assertRaises(OSError):
|
|
104
|
+
provider.schema_for("u")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class FacadeTest(unittest.TestCase):
|
|
108
|
+
def _provider(self) -> MapProvider:
|
|
109
|
+
return MapProvider.from_json({"urn:babel:orders:created": ORDERS})
|
|
110
|
+
|
|
111
|
+
def test_check_valid_invalid_unregistered(self) -> None:
|
|
112
|
+
provider = self._provider()
|
|
113
|
+
self.assertIsNone(check(provider, "urn:babel:orders:created", {"order_id": 1}))
|
|
114
|
+
self.assertIsNone(check(provider, "urn:babel:unknown", {"x": 1})) # opt-in
|
|
115
|
+
self.assertIsNotNone(check(provider, "urn:babel:orders:created", {}))
|
|
116
|
+
|
|
117
|
+
def test_validate_raises_on_invalid(self) -> None:
|
|
118
|
+
with self.assertRaises(InvalidPayloadError):
|
|
119
|
+
validate(self._provider(), "urn:babel:orders:created", {"order_id": "x"})
|
|
120
|
+
|
|
121
|
+
def test_validate_passes_unregistered(self) -> None:
|
|
122
|
+
validate(self._provider(), "urn:babel:unknown", {"anything": True}) # no raise
|
|
123
|
+
|
|
124
|
+
def test_wrap_runs_handler_on_valid(self) -> None:
|
|
125
|
+
calls: List[int] = []
|
|
126
|
+
wrapped = wrap(self._provider(), "urn:babel:orders:created", lambda data, meta: calls.append(1))
|
|
127
|
+
wrapped({"order_id": 1}, {"id": "m1"})
|
|
128
|
+
self.assertEqual(calls, [1])
|
|
129
|
+
|
|
130
|
+
def test_wrap_raises_and_skips_on_invalid(self) -> None:
|
|
131
|
+
calls: List[int] = []
|
|
132
|
+
wrapped = wrap(self._provider(), "urn:babel:orders:created", lambda data, meta: calls.append(1))
|
|
133
|
+
with self.assertRaises(InvalidPayloadError):
|
|
134
|
+
wrapped({}, {"id": "m1"})
|
|
135
|
+
self.assertEqual(calls, [])
|
|
136
|
+
|
|
137
|
+
def test_wrap_runs_handler_for_unregistered_urn(self) -> None:
|
|
138
|
+
calls: List[int] = []
|
|
139
|
+
wrapped = wrap(self._provider(), "urn:babel:unknown", lambda data, meta: calls.append(1))
|
|
140
|
+
wrapped({"anything": True}, {"id": "m1"})
|
|
141
|
+
self.assertEqual(calls, [1])
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
unittest.main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from babelqueue.schema import validate_schema
|
|
8
|
+
|
|
9
|
+
MANIFEST = Path(__file__).parent / "conformance" / "manifest.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PayloadConformanceTest(unittest.TestCase):
|
|
13
|
+
"""Runs the shared cross-SDK payload-schema cases (ADR-0024) from the vendored conformance
|
|
14
|
+
suite: this validator must agree with the Go and PHP ones on each case's ``valid`` flag."""
|
|
15
|
+
|
|
16
|
+
def test_payload_cases_match_across_sdks(self) -> None:
|
|
17
|
+
if not MANIFEST.is_file():
|
|
18
|
+
self.skipTest("vendored conformance suite not present")
|
|
19
|
+
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
20
|
+
section = manifest.get("payload_schema")
|
|
21
|
+
if not isinstance(section, dict):
|
|
22
|
+
self.skipTest("manifest has no payload_schema section")
|
|
23
|
+
|
|
24
|
+
schema = section["schema"]
|
|
25
|
+
cases = section["cases"]
|
|
26
|
+
self.assertTrue(cases)
|
|
27
|
+
for case in cases:
|
|
28
|
+
valid = validate_schema(schema, case["data"]) is None
|
|
29
|
+
self.assertEqual(case["valid"], valid, f"case {case['name']}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
unittest.main()
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
"""Exception hierarchy for BabelQueue."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class BabelQueueError(Exception):
|
|
7
|
-
"""Base for every recoverable BabelQueue error (bad config, empty URN, ...)."""
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class UnknownUrnError(BabelQueueError):
|
|
11
|
-
"""A consumed message carries a URN with no mapped handler (strategy "fail")."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.6.0 → babelqueue-1.8.0}/src/babelqueue/django/management/commands/babelqueue_worker.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.6.0 → babelqueue-1.8.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|