babelqueue 1.9.0__tar.gz → 1.10.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.9.0 → babelqueue-1.10.0}/PKG-INFO +1 -1
- {babelqueue-1.9.0 → babelqueue-1.10.0}/pyproject.toml +1 -1
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/__init__.py +8 -2
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/app.py +14 -12
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/redrive.py +25 -8
- babelqueue-1.10.0/src/babelqueue/replay.py +74 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/transport.py +28 -3
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_redrive.py +1 -0
- babelqueue-1.10.0/tests/test_replay.py +124 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/.gitignore +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/CHANGELOG.md +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/LICENSE +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/README.md +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_app.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_celery.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_codec.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_django.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_otel.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_schema.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_sqs_conformance.py +0 -0
- {babelqueue-1.9.0 → babelqueue-1.10.0}/tests/test_sqs_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10.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.10.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,14 +11,15 @@ 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
|
|
14
|
+
from . import dead_letter, idempotency, 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
18
|
from .idempotency import IdempotencyStore, InMemoryStore
|
|
19
19
|
from .exceptions import BabelQueueError, UnknownUrnError
|
|
20
|
+
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
|
|
20
21
|
from .routing import UnknownUrnStrategy
|
|
21
|
-
from .transport import InMemoryTransport, ReceivedMessage, Transport
|
|
22
|
+
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
|
|
22
23
|
|
|
23
24
|
__version__ = "1.6.0"
|
|
24
25
|
|
|
@@ -33,11 +34,16 @@ __all__ = [
|
|
|
33
34
|
"Transport",
|
|
34
35
|
"InMemoryTransport",
|
|
35
36
|
"ReceivedMessage",
|
|
37
|
+
"HeaderPublisher",
|
|
36
38
|
"BabelQueueError",
|
|
37
39
|
"UnknownUrnError",
|
|
38
40
|
"dead_letter",
|
|
39
41
|
"idempotency",
|
|
40
42
|
"redrive",
|
|
43
|
+
"replay",
|
|
44
|
+
"is_replay",
|
|
45
|
+
"bypass_external_effects",
|
|
46
|
+
"HEADER_REPLAY_BYPASS",
|
|
41
47
|
"IdempotencyStore",
|
|
42
48
|
"InMemoryStore",
|
|
43
49
|
"__version__",
|
|
@@ -25,6 +25,7 @@ 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 .replay import HEADER_REPLAY_BYPASS, _replay_scope
|
|
28
29
|
from .routing import UnknownUrnStrategy
|
|
29
30
|
from .transport import ReceivedMessage, Transport, make_transport
|
|
30
31
|
|
|
@@ -116,18 +117,19 @@ class BabelQueue:
|
|
|
116
117
|
|
|
117
118
|
def dispatch(self, received: ReceivedMessage) -> None:
|
|
118
119
|
"""Route one reserved message to its handler and acknowledge it."""
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
with _replay_scope(bool(received.headers.get(HEADER_REPLAY_BYPASS))):
|
|
121
|
+
envelope = EnvelopeCodec.decode(received.body)
|
|
122
|
+
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
123
|
+
handler = self._handlers.get(urn) if urn else None
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if handler is None:
|
|
127
|
+
self._route_unknown(urn, received, envelope)
|
|
128
|
+
return
|
|
129
|
+
self._invoke(handler, envelope)
|
|
130
|
+
self.transport.ack(received)
|
|
131
|
+
except Exception as exc: # noqa: BLE001 - one bad message must not kill the loop
|
|
132
|
+
self._retry_or_dead_letter(received, envelope, exc)
|
|
131
133
|
|
|
132
134
|
# -- Internals ----------------------------------------------------------
|
|
133
135
|
|
|
@@ -20,12 +20,13 @@ dry-run, or undecodable) are never re-encountered in the same run; a DLQ message
|
|
|
20
20
|
acknowledged only after a successful re-publish, and an undecodable body is restored, not
|
|
21
21
|
dropped.
|
|
22
22
|
|
|
23
|
-
Replay safety
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
Replay safety is sandbox routing (``to_queue``) + ``dry_run``, plus the **Replay-Bypass** guard
|
|
24
|
+
(``bypass=True``): it stamps a ``bq-replay-bypass`` transport header that the runtime surfaces to
|
|
25
|
+
handlers via :func:`babelqueue.is_replay` / :func:`babelqueue.bypass_external_effects`, so a
|
|
26
|
+
replay can skip external side-effects that already fired (don't re-charge, don't re-email) — see
|
|
27
|
+
:mod:`babelqueue.replay` (ADR-0027). The header rides out of band, so the envelope stays frozen;
|
|
28
|
+
it propagates over a real broker only once that broker's transport implements the optional
|
|
29
|
+
:class:`~babelqueue.transport.HeaderPublisher` capability (the in-memory transport does today).
|
|
29
30
|
"""
|
|
30
31
|
|
|
31
32
|
from __future__ import annotations
|
|
@@ -34,7 +35,8 @@ from dataclasses import dataclass, field
|
|
|
34
35
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
35
36
|
|
|
36
37
|
from .codec import EnvelopeCodec
|
|
37
|
-
from .
|
|
38
|
+
from .replay import HEADER_REPLAY_BYPASS
|
|
39
|
+
from .transport import HeaderPublisher, ReceivedMessage, Transport
|
|
38
40
|
|
|
39
41
|
Envelope = Mapping[str, Any]
|
|
40
42
|
Select = Callable[[Envelope], bool]
|
|
@@ -51,6 +53,7 @@ class RedriveItem:
|
|
|
51
53
|
from_queue: str
|
|
52
54
|
to: str # target queue (the plan, even on a dry run; "" when skipped/undecodable)
|
|
53
55
|
redriven: bool # True only when actually re-published to ``to``
|
|
56
|
+
bypassed: bool = False # True when the bq-replay-bypass header was stamped on the message
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
@dataclass
|
|
@@ -70,6 +73,7 @@ def redrive(
|
|
|
70
73
|
max: int = 0,
|
|
71
74
|
dry_run: bool = False,
|
|
72
75
|
select: Optional[Select] = None,
|
|
76
|
+
bypass: bool = False,
|
|
73
77
|
timeout: float = 1.0,
|
|
74
78
|
) -> RedriveResult:
|
|
75
79
|
"""Move dead-lettered messages off ``dlq`` and replay them; see the module docstring."""
|
|
@@ -125,7 +129,9 @@ def redrive(
|
|
|
125
129
|
reset.pop("dead_letter", None)
|
|
126
130
|
reset["attempts"] = 0
|
|
127
131
|
try:
|
|
128
|
-
|
|
132
|
+
item.bypassed = _publish_redriven(
|
|
133
|
+
transport, target, EnvelopeCodec.encode(reset), bypass
|
|
134
|
+
)
|
|
129
135
|
except Exception:
|
|
130
136
|
transport.publish(dlq, message.body) # restore on a publish failure, then surface
|
|
131
137
|
transport.ack(message)
|
|
@@ -150,6 +156,17 @@ def _decoded(body: str) -> Optional[Dict[str, Any]]:
|
|
|
150
156
|
return envelope
|
|
151
157
|
|
|
152
158
|
|
|
159
|
+
def _publish_redriven(transport: Transport, queue: str, body: str, bypass: bool) -> bool:
|
|
160
|
+
"""Re-publish a reset message to ``queue``. When ``bypass`` is set and the transport is a
|
|
161
|
+
:class:`~babelqueue.transport.HeaderPublisher`, stamp the ``bq-replay-bypass`` header and
|
|
162
|
+
return True; otherwise publish plainly and return False."""
|
|
163
|
+
if bypass and isinstance(transport, HeaderPublisher):
|
|
164
|
+
transport.publish_with_headers(queue, body, {HEADER_REPLAY_BYPASS: "1"})
|
|
165
|
+
return True
|
|
166
|
+
transport.publish(queue, body)
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
153
170
|
def _source_queue_of(envelope: Envelope) -> str:
|
|
154
171
|
"""Default redrive target: ``dead_letter.original_queue``, falling back to ``meta.queue``."""
|
|
155
172
|
dead_letter = envelope.get("dead_letter")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Optional Replay-Bypass guard (ADR-0027): skip external side-effects on a deliberate replay.
|
|
2
|
+
|
|
3
|
+
The Python mirror of the Go ``replay.go``. It closes the loop left open by
|
|
4
|
+
:mod:`babelqueue.redrive`: a deliberate replay re-runs the handler, and its external
|
|
5
|
+
side-effects re-fire (a second charge, a duplicate email). :mod:`babelqueue.idempotency` stops
|
|
6
|
+
an *accidental* duplicate; this stops the *intended* reprocess from re-firing effects that
|
|
7
|
+
already happened.
|
|
8
|
+
|
|
9
|
+
``Redrive`` (with ``bypass=True``) stamps a ``bq-replay-bypass`` transport header on a redriven
|
|
10
|
+
message; the runtime surfaces it to the handler via a :class:`contextvars.ContextVar`. A handler
|
|
11
|
+
wraps its external, non-idempotent side in :func:`bypass_external_effects` so a replay re-runs the
|
|
12
|
+
idempotent core but skips effects that already fired::
|
|
13
|
+
|
|
14
|
+
from babelqueue import is_replay, bypass_external_effects
|
|
15
|
+
|
|
16
|
+
@app.handler("urn:babel:orders:created")
|
|
17
|
+
def on_order_created(data, meta):
|
|
18
|
+
save_order(data) # idempotent core — always runs
|
|
19
|
+
bypass_external_effects(lambda: send_email(data)) # external effect — skipped on replay
|
|
20
|
+
|
|
21
|
+
The marker rides **out of band** as a transport header, so the frozen envelope is untouched
|
|
22
|
+
(GR-1). It propagates over a real broker only once that broker's concrete transport implements
|
|
23
|
+
the optional :class:`~babelqueue.transport.HeaderPublisher` capability — a follow-up, like the
|
|
24
|
+
broker bindings; the in-memory transport supports it today, so the path is end-to-end testable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import contextlib
|
|
30
|
+
import contextvars
|
|
31
|
+
from typing import Callable, Iterator, Optional, TypeVar
|
|
32
|
+
|
|
33
|
+
#: The out-of-band transport header :func:`~babelqueue.redrive.redrive` stamps on a replayed
|
|
34
|
+
#: message (with ``bypass=True``) and that the runtime surfaces as :func:`is_replay`.
|
|
35
|
+
HEADER_REPLAY_BYPASS = "bq-replay-bypass"
|
|
36
|
+
|
|
37
|
+
_replay_var: contextvars.ContextVar[bool] = contextvars.ContextVar(
|
|
38
|
+
"babelqueue_replay", default=False
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_replay() -> bool:
|
|
45
|
+
"""Whether the message currently being handled was redriven with the replay-bypass marker.
|
|
46
|
+
|
|
47
|
+
True means this is a deliberate replay, so external side-effects that already happened should
|
|
48
|
+
be skipped. Reads the flag the runtime set on the context from the
|
|
49
|
+
:data:`HEADER_REPLAY_BYPASS` transport header.
|
|
50
|
+
"""
|
|
51
|
+
return _replay_var.get()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def bypass_external_effects(fn: Callable[[], T]) -> Optional[T]:
|
|
55
|
+
"""Run ``fn`` unless the current message is a replay (see :func:`is_replay`), in which case
|
|
56
|
+
skip it and return ``None``.
|
|
57
|
+
|
|
58
|
+
Wrap the external, non-idempotent side of a handler — sending an email, charging a card,
|
|
59
|
+
calling a third party — so a replay re-runs the idempotent core but does not re-fire effects
|
|
60
|
+
that already happened.
|
|
61
|
+
"""
|
|
62
|
+
if is_replay():
|
|
63
|
+
return None
|
|
64
|
+
return fn()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@contextlib.contextmanager
|
|
68
|
+
def _replay_scope(active: bool) -> Iterator[None]:
|
|
69
|
+
"""Internal: mark the current context as a replay (or not) for the span of one dispatch."""
|
|
70
|
+
token = _replay_var.set(active)
|
|
71
|
+
try:
|
|
72
|
+
yield
|
|
73
|
+
finally:
|
|
74
|
+
_replay_var.reset(token)
|
|
@@ -8,8 +8,8 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from abc import ABC, abstractmethod
|
|
10
10
|
from collections import defaultdict, deque
|
|
11
|
-
from dataclasses import dataclass
|
|
12
|
-
from typing import Any, Deque, Dict, Optional
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Deque, Dict, Optional, Protocol, runtime_checkable
|
|
13
13
|
|
|
14
14
|
from .exceptions import BabelQueueError
|
|
15
15
|
|
|
@@ -21,6 +21,9 @@ class ReceivedMessage:
|
|
|
21
21
|
body: str
|
|
22
22
|
queue: str
|
|
23
23
|
handle: Any = None
|
|
24
|
+
#: Out-of-band transport headers a :class:`HeaderPublisher` carried with the message
|
|
25
|
+
#: (e.g. the ``bq-replay-bypass`` marker). Empty for transports that don't surface them.
|
|
26
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
class Transport(ABC):
|
|
@@ -42,20 +45,42 @@ class Transport(ABC):
|
|
|
42
45
|
"""Release any resources (override if needed)."""
|
|
43
46
|
|
|
44
47
|
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class HeaderPublisher(Protocol):
|
|
50
|
+
"""Optional :class:`Transport` capability: publish a body together with out-of-band
|
|
51
|
+
transport headers (e.g. the replay-bypass marker), for brokers that carry per-message
|
|
52
|
+
metadata. A transport that does not implement it simply does not propagate headers —
|
|
53
|
+
callers fall back to plain :meth:`Transport.publish` (ADR-0027)."""
|
|
54
|
+
|
|
55
|
+
def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
|
|
56
|
+
"""Append ``body`` to ``queue`` along with out-of-band ``headers``."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
|
|
45
60
|
class InMemoryTransport(Transport):
|
|
46
61
|
"""In-process transport for tests and broker-free local runs (``memory://``)."""
|
|
47
62
|
|
|
48
63
|
def __init__(self) -> None:
|
|
64
|
+
# Bodies and their out-of-band headers are kept in lockstep parallel deques, so the
|
|
65
|
+
# body storage layout stays a plain Deque[str].
|
|
49
66
|
self._queues: Dict[str, Deque[str]] = defaultdict(deque)
|
|
67
|
+
self._headers: Dict[str, Deque[Dict[str, str]]] = defaultdict(deque)
|
|
50
68
|
|
|
51
69
|
def publish(self, queue: str, body: str) -> None:
|
|
52
70
|
self._queues[queue].append(body)
|
|
71
|
+
self._headers[queue].append({})
|
|
72
|
+
|
|
73
|
+
def publish_with_headers(self, queue: str, body: str, headers: Dict[str, str]) -> None:
|
|
74
|
+
self._queues[queue].append(body)
|
|
75
|
+
self._headers[queue].append(dict(headers))
|
|
53
76
|
|
|
54
77
|
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
55
78
|
dq = self._queues.get(queue)
|
|
56
79
|
if not dq:
|
|
57
80
|
return None
|
|
58
|
-
return ReceivedMessage(
|
|
81
|
+
return ReceivedMessage(
|
|
82
|
+
body=dq.popleft(), queue=queue, headers=self._headers[queue].popleft()
|
|
83
|
+
)
|
|
59
84
|
|
|
60
85
|
def ack(self, message: ReceivedMessage) -> None:
|
|
61
86
|
# Already removed on pop; nothing to do.
|
|
@@ -33,6 +33,7 @@ def test_redrive_to_source():
|
|
|
33
33
|
result = redrive(t, "orders.dlq")
|
|
34
34
|
|
|
35
35
|
assert result.redriven == 1 and result.skipped == 0
|
|
36
|
+
assert result.items[0].bypassed is False # bypass is off by default
|
|
36
37
|
got = _drain(t, "orders")
|
|
37
38
|
assert len(got) == 1
|
|
38
39
|
assert "dead_letter" not in got[0]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for the optional Replay-Bypass guard (ADR-0027)."""
|
|
2
|
+
|
|
3
|
+
from babelqueue import (
|
|
4
|
+
BabelQueue,
|
|
5
|
+
bypass_external_effects,
|
|
6
|
+
dead_letter,
|
|
7
|
+
is_replay,
|
|
8
|
+
)
|
|
9
|
+
from babelqueue.codec import EnvelopeCodec
|
|
10
|
+
from babelqueue.redrive import redrive
|
|
11
|
+
from babelqueue.replay import HEADER_REPLAY_BYPASS, _replay_scope
|
|
12
|
+
from babelqueue.transport import InMemoryTransport, Transport
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _dead_letter(transport, dlq, urn, original_queue, data=None):
|
|
16
|
+
env = EnvelopeCodec.make(urn, data or {}, queue=original_queue)
|
|
17
|
+
annotated = dead_letter.annotate(env, "failed", original_queue, 3, error="boom")
|
|
18
|
+
transport.publish(dlq, EnvelopeCodec.encode(annotated))
|
|
19
|
+
return annotated
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_is_replay_default_false():
|
|
23
|
+
assert is_replay() is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_bypass_runs_when_not_replay():
|
|
27
|
+
ran = []
|
|
28
|
+
|
|
29
|
+
def effect():
|
|
30
|
+
ran.append("x")
|
|
31
|
+
return "did-it"
|
|
32
|
+
|
|
33
|
+
assert bypass_external_effects(effect) == "did-it"
|
|
34
|
+
assert ran == ["x"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_bypass_skips_on_replay_and_scope_resets():
|
|
38
|
+
ran = []
|
|
39
|
+
|
|
40
|
+
def effect():
|
|
41
|
+
ran.append("x")
|
|
42
|
+
raise AssertionError("must not run on a replay")
|
|
43
|
+
|
|
44
|
+
with _replay_scope(True):
|
|
45
|
+
assert is_replay() is True
|
|
46
|
+
assert bypass_external_effects(effect) is None
|
|
47
|
+
assert ran == []
|
|
48
|
+
assert is_replay() is False # the scope reset the contextvar
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_in_memory_transport_carries_headers():
|
|
52
|
+
t = InMemoryTransport()
|
|
53
|
+
t.publish_with_headers("q", "body", {HEADER_REPLAY_BYPASS: "1"})
|
|
54
|
+
msg = t.pop("q", 0)
|
|
55
|
+
assert msg is not None and msg.headers.get(HEADER_REPLAY_BYPASS) == "1"
|
|
56
|
+
# a plain publish carries no headers
|
|
57
|
+
t.publish("q", "plain")
|
|
58
|
+
msg2 = t.pop("q", 0)
|
|
59
|
+
assert msg2 is not None and msg2.headers == {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_redrive_bypass_stamps_header_and_consume_skips_effects():
|
|
63
|
+
t = InMemoryTransport()
|
|
64
|
+
_dead_letter(t, "orders.dlq", "urn:babel:orders:created", "orders", {"order_id": 1})
|
|
65
|
+
|
|
66
|
+
result = redrive(t, "orders.dlq", bypass=True)
|
|
67
|
+
assert result.redriven == 1
|
|
68
|
+
assert result.items[0].bypassed is True
|
|
69
|
+
|
|
70
|
+
# the redriven message on the source queue carries the bypass header
|
|
71
|
+
msg = t.pop("orders", 0)
|
|
72
|
+
assert msg is not None and msg.headers.get(HEADER_REPLAY_BYPASS) == "1"
|
|
73
|
+
|
|
74
|
+
# consume it: the handler sees is_replay and BypassExternalEffects skips the side-effect
|
|
75
|
+
emailed = []
|
|
76
|
+
app = BabelQueue(transport=t)
|
|
77
|
+
|
|
78
|
+
@app.handler("urn:babel:orders:created")
|
|
79
|
+
def on_created(data, meta):
|
|
80
|
+
assert is_replay() is True
|
|
81
|
+
bypass_external_effects(lambda: emailed.append(data))
|
|
82
|
+
|
|
83
|
+
app.dispatch(msg)
|
|
84
|
+
assert emailed == [] # the external side-effect was skipped on the bypassed replay
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_normal_delivery_is_not_a_replay():
|
|
88
|
+
t = InMemoryTransport()
|
|
89
|
+
app = BabelQueue(transport=t)
|
|
90
|
+
fired = []
|
|
91
|
+
|
|
92
|
+
@app.handler("urn:babel:orders:created")
|
|
93
|
+
def on_created(data, meta):
|
|
94
|
+
assert is_replay() is False
|
|
95
|
+
bypass_external_effects(lambda: fired.append(data))
|
|
96
|
+
|
|
97
|
+
app.publish("urn:babel:orders:created", {"order_id": 9})
|
|
98
|
+
app.consume(max_messages=1, timeout=0)
|
|
99
|
+
assert fired == [{"order_id": 9}] # effect runs on a normal (non-replay) delivery
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _PlainTransport(Transport):
|
|
103
|
+
"""A Transport that is deliberately NOT a HeaderPublisher (no publish_with_headers)."""
|
|
104
|
+
|
|
105
|
+
def __init__(self):
|
|
106
|
+
self._inner = InMemoryTransport()
|
|
107
|
+
|
|
108
|
+
def publish(self, queue, body):
|
|
109
|
+
self._inner.publish(queue, body)
|
|
110
|
+
|
|
111
|
+
def pop(self, queue, timeout=1.0):
|
|
112
|
+
return self._inner.pop(queue, timeout)
|
|
113
|
+
|
|
114
|
+
def ack(self, message):
|
|
115
|
+
self._inner.ack(message)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_redrive_bypass_without_header_support_falls_back():
|
|
119
|
+
t = _PlainTransport()
|
|
120
|
+
_dead_letter(t, "orders.dlq", "urn:babel:orders:created", "orders")
|
|
121
|
+
|
|
122
|
+
result = redrive(t, "orders.dlq", bypass=True)
|
|
123
|
+
assert result.redriven == 1
|
|
124
|
+
assert result.items[0].bypassed is False # no-op without a HeaderPublisher
|
|
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.9.0 → babelqueue-1.10.0}/src/babelqueue/django/management/commands/__init__.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
|
|
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.9.0 → babelqueue-1.10.0}/tests/conformance/schema/message-envelope.schema.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
|