babelqueue 1.5.0__tar.gz → 1.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {babelqueue-1.5.0 → babelqueue-1.6.0}/CHANGELOG.md +28 -1
  2. {babelqueue-1.5.0 → babelqueue-1.6.0}/PKG-INFO +30 -4
  3. {babelqueue-1.5.0 → babelqueue-1.6.0}/README.md +29 -3
  4. {babelqueue-1.5.0 → babelqueue-1.6.0}/pyproject.toml +1 -1
  5. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/__init__.py +1 -1
  6. babelqueue-1.6.0/src/babelqueue/redis_transport.py +254 -0
  7. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/manifest.json +26 -0
  8. babelqueue-1.6.0/tests/test_redis_transport.py +396 -0
  9. babelqueue-1.5.0/src/babelqueue/redis_transport.py +0 -53
  10. babelqueue-1.5.0/tests/test_redis_transport.py +0 -78
  11. {babelqueue-1.5.0 → babelqueue-1.6.0}/.github/FUNDING.yml +0 -0
  12. {babelqueue-1.5.0 → babelqueue-1.6.0}/.github/workflows/ci.yml +0 -0
  13. {babelqueue-1.5.0 → babelqueue-1.6.0}/.github/workflows/release.yml +0 -0
  14. {babelqueue-1.5.0 → babelqueue-1.6.0}/.gitignore +0 -0
  15. {babelqueue-1.5.0 → babelqueue-1.6.0}/LICENSE +0 -0
  16. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/app.py +0 -0
  17. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/artemis_transport.py +0 -0
  18. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/asb_transport.py +0 -0
  19. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/celery.py +0 -0
  20. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/codec.py +0 -0
  21. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/contracts.py +0 -0
  22. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/dead_letter.py +0 -0
  23. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/__init__.py +0 -0
  24. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/apps.py +0 -0
  25. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/management/__init__.py +0 -0
  26. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  27. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  28. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/exceptions.py +0 -0
  29. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/kafka_transport.py +0 -0
  30. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/pika_transport.py +0 -0
  31. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/pulsar_transport.py +0 -0
  32. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/py.typed +0 -0
  33. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/routing.py +0 -0
  34. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/sqs_transport.py +0 -0
  35. {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/transport.py +0 -0
  36. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  37. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  38. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  39. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/order-created.json +0 -0
  40. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  41. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  42. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  43. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/fixtures/dead-lettered.json +0 -0
  44. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/fixtures/order-created.json +0 -0
  45. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_app.py +0 -0
  46. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_artemis_conformance.py +0 -0
  47. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_artemis_transport.py +0 -0
  48. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_asb_conformance.py +0 -0
  49. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_asb_transport.py +0 -0
  50. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_celery.py +0 -0
  51. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_codec.py +0 -0
  52. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_conformance.py +0 -0
  53. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_dead_letter.py +0 -0
  54. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_django.py +0 -0
  55. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_kafka_conformance.py +0 -0
  56. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_kafka_transport.py +0 -0
  57. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_overhead.py +0 -0
  58. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_pika_transport.py +0 -0
  59. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_pulsar_conformance.py +0 -0
  60. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_pulsar_transport.py +0 -0
  61. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_sqs_conformance.py +0 -0
  62. {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_sqs_transport.py +0 -0
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  The envelope wire format is versioned separately by `meta.schema_version`
8
8
  (currently **1**) — see the contract at [babelqueue.com](https://babelqueue.com).
9
9
 
10
+ ## [Unreleased]
11
+
12
+ ## [1.6.0] - 2026-06-14
13
+
14
+ ### Added
15
+ - **Redis/Laravel reservation parity** — the Redis transport can now consume a
16
+ **shared** Laravel BabelQueue Redis queue using Laravel's reserved-set / reliable-queue
17
+ semantics, not just a Python-owned queue. Enable it with the `laravel=1` URL flag
18
+ (`redis://host:6379/0?laravel=1`) or `RedisTransport(..., laravel_compat=True)`. In this
19
+ mode the key layout is Laravel's stock Redis queue ([§1 of the broker-bindings
20
+ contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)): a `queues:<name>`
21
+ ready list, a `queues:<name>:reserved` **sorted set** scored by a `retry_after` deadline
22
+ (default 60s), a `queues:<name>:delayed` set, and a `queues:<name>:notify` wake-up list.
23
+ Reserve, ack and release run the **byte-for-byte same Lua scripts** Laravel uses, so the
24
+ reserved member a Python worker writes is identical to a Laravel worker's — either side can
25
+ ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share one
26
+ Redis queue without losing or double-processing messages. Before each pop, expired
27
+ reserved/delayed jobs migrate back to the ready list, so a crashed worker's in-flight job
28
+ is re-reserved exactly as Laravel does. Tunable via `?prefix=` / `?retry_after=` (or the
29
+ `key_prefix` / `retry_after` constructor args). The default Python-owned reliable-queue
30
+ mode (`BLMOVE` + `<queue>:processing` list) is unchanged and stays the default, so existing
31
+ callers are unaffected. The reservation logic is fully unit-tested with an injected
32
+ in-memory Redis double (no `redis` package, no broker); a live cross-runtime PHP↔Python
33
+ shared-queue round-trip is covered by the integration suite where a real Redis is present.
34
+ The envelope is unchanged (`schema_version: 1`); this is purely additive. Ships as a MINOR.
35
+
10
36
  ## [1.5.0] - 2026-06-13
11
37
 
12
38
  ### Added
@@ -173,7 +199,8 @@ reference at [babelqueue.com](https://babelqueue.com).
173
199
  - Pre-1.0: the public API may change before the `1.0.0` tag.
174
200
  - The core has **zero runtime dependencies** (standard library only); Python `>=3.9`.
175
201
 
176
- [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.2.0...HEAD
202
+ [Unreleased]: https://github.com/BabelQueue/babelqueue-python/compare/v1.6.0...HEAD
203
+ [1.6.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.5.0...v1.6.0
177
204
  [1.2.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.1.0...v1.2.0
178
205
  [1.1.0]: https://github.com/BabelQueue/babelqueue-python/compare/v1.0.0...v1.1.0
179
206
  [1.0.0]: https://github.com/BabelQueue/babelqueue-python/compare/v0.5.0...v1.0.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.5.0
3
+ Version: 1.6.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
@@ -164,9 +164,35 @@ app.run() # consume forever (Ctrl-C to stop)
164
164
  - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
165
165
  envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
166
166
  messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
167
- - **Transports:** `redis://` (reliable-queue pattern), `amqp://` (RabbitMQ via
168
- `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
169
- tests/local). Bring your own by passing `transport=...`.
167
+ - **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
168
+ Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
169
+ see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
170
+ and `memory://` (in-process, great for tests/local). Bring your own by passing
171
+ `transport=...`.
172
+
173
+ ### Sharing a Redis queue with Laravel
174
+
175
+ By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
176
+ `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). To consume a
177
+ **shared** Laravel BabelQueue Redis queue instead, enable Laravel-compatible mode:
178
+
179
+ ```python
180
+ app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
181
+ # or: RedisTransport("redis://localhost:6379/0", laravel_compat=True)
182
+ ```
183
+
184
+ This replicates Laravel's stock Redis reservation exactly ([§1 of the
185
+ broker-bindings contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)):
186
+ the ready list is `queues:<name>`, reservations move into the `queues:<name>:reserved`
187
+ **sorted set** scored by a `retry_after` deadline (default 60s), with a
188
+ `queues:<name>:delayed` set and a `queues:<name>:notify` wake-up list. Reserve, ack
189
+ and release run the **byte-for-byte same Lua scripts** Laravel uses, so the reserved
190
+ member a Python worker writes is identical to a Laravel worker's — either side can
191
+ ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
192
+ one queue without losing or double-processing messages. Before each pop, expired
193
+ reserved/delayed jobs migrate back to the ready list, so a crashed worker's
194
+ in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=60`
195
+ (or the `key_prefix` / `retry_after` constructor args).
170
196
 
171
197
  ## Framework adapters — Celery & Django
172
198
 
@@ -114,9 +114,35 @@ app.run() # consume forever (Ctrl-C to stop)
114
114
  - **Retry & dead-letter:** failures are retried up to `max_attempts` (bumping the
115
115
  envelope's `attempts`); enable `dead_letter=True` to quarantine exhausted
116
116
  messages on `<queue>.dlq`. `on_unknown_urn` = `fail` | `delete` | `release` | `dead_letter`.
117
- - **Transports:** `redis://` (reliable-queue pattern), `amqp://` (RabbitMQ via
118
- `pika`, with the contract AMQP properties) and `memory://` (in-process, great for
119
- tests/local). Bring your own by passing `transport=...`.
117
+ - **Transports:** `redis://` (reliable-queue pattern; add `?laravel=1` to share a
118
+ Redis queue with a Laravel BabelQueue worker using its reserved-set semantics —
119
+ see below), `amqp://` (RabbitMQ via `pika`, with the contract AMQP properties)
120
+ and `memory://` (in-process, great for tests/local). Bring your own by passing
121
+ `transport=...`.
122
+
123
+ ### Sharing a Redis queue with Laravel
124
+
125
+ By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;
126
+ `BLMOVE` into a `<queue>:processing` list to reserve; `LREM` on ack). To consume a
127
+ **shared** Laravel BabelQueue Redis queue instead, enable Laravel-compatible mode:
128
+
129
+ ```python
130
+ app = BabelQueue("redis://localhost:6379/0?laravel=1", queue="orders")
131
+ # or: RedisTransport("redis://localhost:6379/0", laravel_compat=True)
132
+ ```
133
+
134
+ This replicates Laravel's stock Redis reservation exactly ([§1 of the
135
+ broker-bindings contract](https://babelqueue.com/docs/spec/1.x/broker-bindings#redis)):
136
+ the ready list is `queues:<name>`, reservations move into the `queues:<name>:reserved`
137
+ **sorted set** scored by a `retry_after` deadline (default 60s), with a
138
+ `queues:<name>:delayed` set and a `queues:<name>:notify` wake-up list. Reserve, ack
139
+ and release run the **byte-for-byte same Lua scripts** Laravel uses, so the reserved
140
+ member a Python worker writes is identical to a Laravel worker's — either side can
141
+ ack (`ZREM`) the other's reservation, so a Python worker and a Laravel worker share
142
+ one queue without losing or double-processing messages. Before each pop, expired
143
+ reserved/delayed jobs migrate back to the ready list, so a crashed worker's
144
+ in-flight job is re-reserved. Options: `?prefix=queues:` and `?retry_after=60`
145
+ (or the `key_prefix` / `retry_after` constructor args).
120
146
 
121
147
  ## Framework adapters — Celery & Django
122
148
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.5.0"
7
+ version = "1.6.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"
@@ -19,7 +19,7 @@ from .exceptions import BabelQueueError, UnknownUrnError
19
19
  from .routing import UnknownUrnStrategy
20
20
  from .transport import InMemoryTransport, ReceivedMessage, Transport
21
21
 
22
- __version__ = "1.2.0"
22
+ __version__ = "1.6.0"
23
23
 
24
24
  __all__ = [
25
25
  "BabelQueue",
@@ -0,0 +1,254 @@
1
+ """Redis transport. Requires the ``redis`` extra:
2
+
3
+ pip install "babelqueue[redis]"
4
+
5
+ Two reservation modes share one class, selected by ``laravel_compat``:
6
+
7
+ **Python-owned (default).** Producing is ``RPUSH queue body``; consuming atomically
8
+ moves the head to a per-queue processing list (``BLMOVE``) so an in-flight message
9
+ survives a worker crash, and ``ack`` removes it from that processing list. Simple,
10
+ self-contained, and the right choice for a queue this runtime owns end-to-end.
11
+
12
+ **Laravel-compatible (``laravel_compat=True``).** Replicates Laravel's stock Redis
13
+ queue reservation so a Python worker can share one Redis queue with a PHP/Laravel
14
+ worker without losing or double-processing messages (§1 of the broker-bindings
15
+ contract). The key layout is Laravel's: a ``queues:<name>`` ready list, a
16
+ ``queues:<name>:reserved`` sorted set scored by a retry-after deadline, a
17
+ ``queues:<name>:delayed`` sorted set, and a ``queues:<name>:notify`` wake-up list.
18
+ Reserve/ack/release run the **byte-for-byte same Lua scripts** Laravel uses, so the
19
+ reserved-set member a Python worker writes is identical to what a Laravel worker
20
+ writes — either side can therefore ack (``ZREM``) the other's reservation. Before
21
+ each pop the transport migrates expired reserved/delayed jobs back to the ready
22
+ list, so a crashed worker's in-flight job is re-reserved exactly as Laravel does.
23
+
24
+ The envelope is unchanged (``schema_version`` stays 1); Redis is purely additive.
25
+
26
+ URL form: ``redis://host:port/db[?laravel=1&prefix=queues:&retry_after=60]``.
27
+ For richer setups, build the transport directly and pass ``BabelQueue(transport=...)``.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import time
33
+ from typing import Any, Optional
34
+ from urllib.parse import parse_qs, urlsplit
35
+
36
+ from .transport import ReceivedMessage, Transport
37
+
38
+ # Laravel's stock Redis queue Lua scripts (Illuminate\Queue\LuaScripts). Replicated
39
+ # verbatim so the reserved-set member byte form matches Laravel's — that exact match
40
+ # is what lets a Python and a Laravel worker ack each other's reservations.
41
+
42
+ # KEYS[1]=ready list, KEYS[2]=notify list; ARGV[1]=payload.
43
+ _PUSH = """
44
+ redis.call('rpush', KEYS[1], ARGV[1])
45
+ redis.call('rpush', KEYS[2], 1)
46
+ """
47
+
48
+ # KEYS[1]=ready, KEYS[2]=reserved, KEYS[3]=notify; ARGV[1]=reserved-until score.
49
+ # Returns {original_job, reserved_member}; the reserved member is the attempts+1
50
+ # re-encoding stored in the reserved set and is the ack handle.
51
+ _POP = """
52
+ local job = redis.call('lpop', KEYS[1])
53
+ local reserved = false
54
+
55
+ if(job ~= false) then
56
+ reserved = cjson.decode(job)
57
+ reserved['attempts'] = reserved['attempts'] + 1
58
+ reserved = cjson.encode(reserved)
59
+ redis.call('zadd', KEYS[2], ARGV[1], reserved)
60
+ redis.call('lpop', KEYS[3])
61
+ end
62
+
63
+ return {job, reserved}
64
+ """
65
+
66
+ # KEYS[1]=delayed, KEYS[2]=reserved; ARGV[1]=reserved member, ARGV[2]=available-at.
67
+ _RELEASE = """
68
+ redis.call('zrem', KEYS[2], ARGV[1])
69
+ redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])
70
+ return true
71
+ """
72
+
73
+ # KEYS[1]=from (reserved/delayed), KEYS[2]=ready, KEYS[3]=notify; ARGV[1]=now,
74
+ # ARGV[2]=batch size (-1 = all). Moves jobs whose score has expired back to ready.
75
+ _MIGRATE = """
76
+ local val = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1], 'limit', 0, ARGV[2])
77
+
78
+ if(next(val) ~= nil) then
79
+ redis.call('zremrangebyrank', KEYS[1], 0, #val - 1)
80
+
81
+ for i = 1, #val, 100 do
82
+ redis.call('rpush', KEYS[2], unpack(val, i, math.min(i+99, #val)))
83
+ for j = i, math.min(i+99, #val) do
84
+ redis.call('rpush', KEYS[3], 1)
85
+ end
86
+ end
87
+ end
88
+
89
+ return val
90
+ """
91
+
92
+
93
+ class RedisTransport(Transport):
94
+ def __init__(
95
+ self,
96
+ url: str = "redis://localhost:6379/0",
97
+ *,
98
+ client: Any = None,
99
+ processing_suffix: str = ":processing",
100
+ laravel_compat: bool = False,
101
+ key_prefix: str = "queues:",
102
+ retry_after: int = 60,
103
+ block_for: Optional[float] = None,
104
+ ) -> None:
105
+ parts = urlsplit(url) if url else urlsplit("redis://")
106
+ q = parse_qs(parts.query)
107
+
108
+ self._processing_suffix = processing_suffix
109
+ self._laravel_compat = laravel_compat or _qbool(q, "laravel") or _qbool(q, "laravel_compat")
110
+ self._key_prefix = key_prefix if "prefix" not in q else (_q1(q, "prefix") or key_prefix)
111
+ self._retry_after = retry_after if "retry_after" not in q else _qint(q, "retry_after", retry_after)
112
+ self._block_for = block_for
113
+
114
+ if client is not None:
115
+ self._redis = client
116
+ return
117
+ try:
118
+ import redis
119
+ except ImportError as exc: # pragma: no cover - import guard
120
+ raise ImportError(
121
+ "RedisTransport requires the 'redis' package. Install with "
122
+ 'pip install "babelqueue[redis]".'
123
+ ) from exc
124
+ self._redis = redis.Redis.from_url(url, decode_responses=True)
125
+
126
+ # -- key helpers --------------------------------------------------------
127
+
128
+ def _processing(self, queue: str) -> str:
129
+ return f"{queue}{self._processing_suffix}"
130
+
131
+ def _ready(self, queue: str) -> str:
132
+ """The Laravel ready-list key for a logical queue name (``queues:<name>``)."""
133
+ return f"{self._key_prefix}{queue}"
134
+
135
+ # -- Transport ----------------------------------------------------------
136
+
137
+ def publish(self, queue: str, body: str) -> None:
138
+ if not self._laravel_compat:
139
+ self._redis.rpush(queue, body)
140
+ return
141
+ ready = self._ready(queue)
142
+ self._redis.eval(_PUSH, 2, ready, f"{ready}:notify", body)
143
+
144
+ def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
145
+ if not self._laravel_compat:
146
+ return self._pop_owned(queue, timeout)
147
+ return self._pop_laravel(queue, timeout)
148
+
149
+ def ack(self, message: ReceivedMessage) -> None:
150
+ if not self._laravel_compat:
151
+ self._redis.lrem(self._processing(message.queue), 1, message.handle)
152
+ return
153
+ if not message.handle:
154
+ return
155
+ self._redis.zrem(f"{self._ready(message.queue)}:reserved", message.handle)
156
+
157
+ def release(self, message: ReceivedMessage, delay: int = 0) -> None:
158
+ """Return a reserved Laravel job to the (delayed) ready set for retry.
159
+
160
+ No-op in Python-owned mode (the runtime re-publishes instead). In
161
+ Laravel-compatible mode this is ``ZREM :reserved`` + ``ZADD :delayed`` —
162
+ the exact bookkeeping Laravel's release performs.
163
+ """
164
+ if not self._laravel_compat or not message.handle:
165
+ return
166
+ ready = self._ready(message.queue)
167
+ self._redis.eval(
168
+ _RELEASE, 2, f"{ready}:delayed", f"{ready}:reserved",
169
+ message.handle, int(time.time()) + int(delay),
170
+ )
171
+
172
+ def close(self) -> None: # pragma: no cover
173
+ self._redis.close()
174
+
175
+ # -- Python-owned reliable queue ----------------------------------------
176
+
177
+ def _pop_owned(self, queue: str, timeout: float) -> Optional[ReceivedMessage]:
178
+ # redis-py types the BLMOVE timeout as int, but Redis accepts a float
179
+ # (sub-second) timeout; passing it through is correct at runtime.
180
+ body = self._redis.blmove(queue, self._processing(queue), timeout, "LEFT", "RIGHT") # type: ignore[arg-type]
181
+ if body is None:
182
+ return None
183
+ text = _as_text(body)
184
+ return ReceivedMessage(body=text, queue=queue, handle=text)
185
+
186
+ # -- Laravel-compatible reservation -------------------------------------
187
+
188
+ def _pop_laravel(self, queue: str, timeout: float) -> Optional[ReceivedMessage]:
189
+ ready = self._ready(queue)
190
+ self._migrate(ready)
191
+
192
+ result = self._redis.eval(
193
+ _POP, 3, ready, f"{ready}:reserved", f"{ready}:notify",
194
+ int(time.time()) + self._retry_after,
195
+ )
196
+ job, reserved = _pop_result(result)
197
+ if job is None or reserved is None:
198
+ # Nothing ready right now; optionally block on the notify list so a
199
+ # concurrently-pushed job wakes us, then retry once (mirrors Laravel).
200
+ block = self._block_for if self._block_for is not None else timeout
201
+ if block and block > 0 and self._redis.blpop([f"{ready}:notify"], block):
202
+ result = self._redis.eval(
203
+ _POP, 3, ready, f"{ready}:reserved", f"{ready}:notify",
204
+ int(time.time()) + self._retry_after,
205
+ )
206
+ job, reserved = _pop_result(result)
207
+ if job is None or reserved is None:
208
+ return None
209
+
210
+ # The reserved member (attempts already incremented) is both the consumed
211
+ # body and the ack handle, so ZREM removes exactly this reservation.
212
+ return ReceivedMessage(body=_as_text(reserved), queue=queue, handle=_as_text(reserved))
213
+
214
+ def _migrate(self, ready: str) -> None:
215
+ now = int(time.time())
216
+ # Delayed jobs that are due, then reserved jobs whose retry-after lapsed
217
+ # (a crashed worker's in-flight job) — both move back to the ready list.
218
+ self._redis.eval(_MIGRATE, 3, f"{ready}:delayed", ready, f"{ready}:notify", now, -1)
219
+ self._redis.eval(_MIGRATE, 3, f"{ready}:reserved", ready, f"{ready}:notify", now, -1)
220
+
221
+
222
+ def _as_text(value: Any) -> str:
223
+ """Normalise a Redis reply to ``str`` (bytes when ``decode_responses`` is off)."""
224
+ return value if isinstance(value, str) else value.decode()
225
+
226
+
227
+ def _pop_result(result: Any) -> tuple[Optional[Any], Optional[Any]]:
228
+ """Unpack the ``{job, reserved}`` reply; ``false`` Lua values arrive as ``None``."""
229
+ if not result or len(result) < 2:
230
+ return None, None
231
+ job, reserved = result[0], result[1]
232
+ if not job or not reserved:
233
+ return None, None
234
+ return job, reserved
235
+
236
+
237
+ def _q1(q: dict, key: str) -> Optional[str]:
238
+ values = q.get(key)
239
+ return values[0] if values else None
240
+
241
+
242
+ def _qint(q: dict, key: str, default: int) -> int:
243
+ v = _q1(q, key)
244
+ if v is None:
245
+ return default
246
+ try:
247
+ return int(v)
248
+ except ValueError: # pragma: no cover - defensive
249
+ return default
250
+
251
+
252
+ def _qbool(q: dict, key: str) -> bool:
253
+ v = _q1(q, key)
254
+ return v is not None and v.lower() in ("1", "true", "yes", "on")
@@ -200,5 +200,31 @@
200
200
  { "name": "native-equals-body", "body_attempts": 3, "delivery_count": 3, "expected_attempts": 3 }
201
201
  ]
202
202
  }
203
+ },
204
+ "redis": {
205
+ "description": "Redis binding conformance (broker-bindings.md §1 — the reliable-queue list pattern). Redis lists carry no native metadata, so there is NO property projection: the queue element IS the canonical envelope JSON, byte-for-byte, with NO wrapping (unlike BullMQ's job structure) and NO added fields. The reserve/ack reliability (Laravel's reserved-sorted-set vs the App runtimes' processing-list) and attempts handling are SDK-runtime-specific and proven by the live interop round-trip, not a static fixture. The one cross-SDK invariant locked here is payload identity.",
206
+ "payload_identity": {
207
+ "description": "On produce, the transport MUST store the envelope JSON byte-for-byte as the queue element (RPUSH / equivalent); on reserve it MUST return that element unchanged — no wrapping, no envelope mutation, no added metadata. A §1 SDK round-trips fixtures/order-created.json through produce→reserve and gets back the identical bytes. Applies to every Redis-producing SDK.",
208
+ "envelope_file": "fixtures/order-created.json"
209
+ }
210
+ },
211
+ "rabbitmq": {
212
+ "description": "RabbitMQ binding conformance (broker-bindings.md §2 — AMQP 0-9-1). The envelope body stays byte-identical (the 'cases' above); this locks the native AMQP-0-9-1 property projection every producer emits. NOTE: consume-side attempts handling is NOT in scope here because it is runtime-specific — the Laravel driver reads the authoritative x-attempts header (§2.5), while the App-runtime SDKs (Go/Python) keep attempts in the body and republish with attempts+1 (the x-attempts header is then a redundant produce-side mirror). Both stay consistent because Laravel increments the body's attempts too. Per-message values reuse fixtures/order-created.json.",
213
+ "property_projection": {
214
+ "description": "On produce, the transport MUST project these native AMQP 0-9-1 properties + headers from the envelope. Header values are NATIVE-typed (AMQP 0-9-1 field-tables carry typed values — integers stay integers, NOT stringified like Kafka/Pulsar): type = job (the URN, route on it without reading the body), correlation_id = trace_id, message_id = meta.id, app_id = 'babelqueue', content_type = 'application/json', header x-schema-version = meta.schema_version (number), x-source-lang = meta.lang, x-attempts = attempts (number). The body is the byte-identical envelope. Applies to every RabbitMQ-producing SDK.",
215
+ "envelope_file": "fixtures/order-created.json",
216
+ "properties": {
217
+ "type": "urn:babel:orders:created",
218
+ "correlation_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
219
+ "message_id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
220
+ "app_id": "babelqueue",
221
+ "content_type": "application/json"
222
+ },
223
+ "headers": {
224
+ "x-schema-version": 1,
225
+ "x-source-lang": "php",
226
+ "x-attempts": 0
227
+ }
228
+ }
203
229
  }
204
230
  }
@@ -0,0 +1,396 @@
1
+ """Tests for the Redis transport.
2
+
3
+ The unit tests inject a tiny in-memory Redis double, so they run without the
4
+ ``redis`` package and without a broker. The double emulates exactly the commands
5
+ the transport uses (``rpush``/``lpop``/``blmove``/``lrem``/``zadd``/``zrem``/
6
+ ``blpop``) plus the three Laravel Lua scripts the Laravel-compatible mode runs,
7
+ so the reserved-set / reliable-queue semantics are exercised end-to-end.
8
+
9
+ A separate integration test round-trips against a real Redis and is skipped unless
10
+ the ``redis`` package and a reachable broker are present (CI runs it).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import unittest
18
+ import uuid
19
+
20
+ from babelqueue import BabelQueue, EnvelopeCodec
21
+ from babelqueue.redis_transport import RedisTransport, _pop_result, _qint
22
+ from babelqueue.transport import ReceivedMessage
23
+
24
+
25
+ class FakeRedis:
26
+ """In-memory stand-in for the subset of Redis the transport uses.
27
+
28
+ Lists are Python lists; sorted sets are ``{member: score}`` dicts. ``eval``
29
+ recognises the three Laravel Lua scripts (push / pop / migrate / release) by
30
+ keyword and emulates their effect — including the ``cjson`` re-encode of the
31
+ reserved member, which is what makes Python's and Laravel's reserved members
32
+ byte-identical in production.
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self.lists: dict[str, list[str]] = {}
37
+ self.zsets: dict[str, dict[str, float]] = {}
38
+
39
+ # -- plain commands (Python-owned mode) ---------------------------------
40
+
41
+ def rpush(self, key, value): # noqa: ANN001
42
+ self.lists.setdefault(key, []).append(value)
43
+ return len(self.lists[key])
44
+
45
+ def lpop(self, key): # noqa: ANN001
46
+ lst = self.lists.get(key)
47
+ return lst.pop(0) if lst else None
48
+
49
+ def llen(self, key): # noqa: ANN001
50
+ return len(self.lists.get(key, []))
51
+
52
+ def lrange(self, key, start, stop): # noqa: ANN001
53
+ lst = self.lists.get(key, [])
54
+ end = len(lst) if stop == -1 else stop + 1
55
+ return lst[start:end]
56
+
57
+ def blmove(self, src, dst, timeout, src_dir, dst_dir): # noqa: ANN001, ARG002
58
+ lst = self.lists.get(src)
59
+ if not lst:
60
+ return None
61
+ value = lst.pop(0)
62
+ self.lists.setdefault(dst, []).append(value)
63
+ return value
64
+
65
+ def lrem(self, key, count, value): # noqa: ANN001, ARG002
66
+ lst = self.lists.get(key, [])
67
+ if value in lst:
68
+ lst.remove(value)
69
+ return 1
70
+ return 0
71
+
72
+ def blpop(self, keys, timeout): # noqa: ANN001, ARG002
73
+ for key in keys:
74
+ lst = self.lists.get(key)
75
+ if lst:
76
+ return (key, lst.pop(0))
77
+ return None
78
+
79
+ def zadd(self, key, mapping): # noqa: ANN001
80
+ self.zsets.setdefault(key, {}).update(mapping)
81
+
82
+ def zrem(self, key, member): # noqa: ANN001
83
+ z = self.zsets.get(key, {})
84
+ return 1 if z.pop(member, None) is not None else 0
85
+
86
+ def zcard(self, key): # noqa: ANN001
87
+ return len(self.zsets.get(key, {}))
88
+
89
+ def delete(self, *keys): # noqa: ANN001
90
+ for key in keys:
91
+ self.lists.pop(key, None)
92
+ self.zsets.pop(key, None)
93
+
94
+ def close(self):
95
+ pass
96
+
97
+ # -- Lua scripts (Laravel-compatible mode) ------------------------------
98
+
99
+ def eval(self, script, numkeys, *args): # noqa: ANN001
100
+ keys = list(args[:numkeys])
101
+ argv = list(args[numkeys:])
102
+ if "rpush" in script and "lpop" not in script and "zrange" not in script:
103
+ return self._push(keys, argv)
104
+ if "'lpop'" in script and "zadd" in script:
105
+ return self._pop(keys, argv)
106
+ if "zrangebyscore" in script:
107
+ return self._migrate(keys, argv)
108
+ if "'zrem'" in script and "zadd" in script:
109
+ return self._release(keys, argv)
110
+ raise AssertionError("unrecognised Lua script")
111
+
112
+ def _push(self, keys, argv): # noqa: ANN001
113
+ ready, notify = keys
114
+ self.rpush(ready, argv[0])
115
+ self.rpush(notify, "1")
116
+
117
+ def _pop(self, keys, argv): # noqa: ANN001
118
+ ready, reserved_key, notify = keys
119
+ job = self.lpop(ready)
120
+ if job is None:
121
+ return [None, None]
122
+ decoded = json.loads(job)
123
+ decoded["attempts"] = decoded.get("attempts", 0) + 1
124
+ reserved = json.dumps(decoded, separators=(",", ":"))
125
+ self.zadd(reserved_key, {reserved: float(argv[0])})
126
+ self.lpop(notify)
127
+ return [job, reserved]
128
+
129
+ def _migrate(self, keys, argv): # noqa: ANN001
130
+ src, dst, notify = keys
131
+ now = float(argv[0])
132
+ z = self.zsets.get(src, {})
133
+ due = [m for m, score in z.items() if score <= now]
134
+ for member in due:
135
+ z.pop(member, None)
136
+ self.rpush(dst, member)
137
+ self.rpush(notify, "1")
138
+ return due
139
+
140
+ def _release(self, keys, argv): # noqa: ANN001
141
+ delayed, reserved_key = keys
142
+ member, score = argv[0], float(argv[1])
143
+ self.zrem(reserved_key, member)
144
+ self.zadd(delayed, {member: score})
145
+ return True
146
+
147
+
148
+ class RedisOwnedModeTest(unittest.TestCase):
149
+ """The default Python-owned reliable-queue mode (BLMOVE + processing list)."""
150
+
151
+ def _tr(self):
152
+ fake = FakeRedis()
153
+ return RedisTransport("redis://", client=fake), fake
154
+
155
+ def test_publish_uses_raw_queue_key(self):
156
+ tr, fake = self._tr()
157
+ tr.publish("orders", "body-1")
158
+ self.assertEqual(fake.lists["orders"], ["body-1"])
159
+
160
+ def test_pop_moves_to_processing_and_ack_removes(self):
161
+ tr, fake = self._tr()
162
+ tr.publish("orders", "body-1")
163
+ msg = tr.pop("orders", timeout=0)
164
+ self.assertEqual(msg.body, "body-1")
165
+ self.assertEqual(fake.lists["orders:processing"], ["body-1"])
166
+ self.assertEqual(fake.lists["orders"], [])
167
+ tr.ack(msg)
168
+ self.assertEqual(fake.lists["orders:processing"], [])
169
+
170
+ def test_pop_empty_returns_none(self):
171
+ tr, _ = self._tr()
172
+ self.assertIsNone(tr.pop("orders", timeout=0))
173
+
174
+
175
+ class RedisLaravelCompatTest(unittest.TestCase):
176
+ """Laravel reserved-set parity: shared queue, reserve / ack / release / migrate."""
177
+
178
+ def _tr(self, **kw):
179
+ fake = FakeRedis()
180
+ return RedisTransport("redis://", client=fake, laravel_compat=True, **kw), fake
181
+
182
+ def test_publish_uses_laravel_keys_and_notify(self):
183
+ tr, fake = self._tr()
184
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1}, queue="orders")
185
+ tr.publish("orders", EnvelopeCodec.encode(env))
186
+ # Laravel prefixes logical names with "queues:" and pushes a notify token.
187
+ self.assertEqual(fake.llen("queues:orders"), 1)
188
+ self.assertEqual(fake.llen("queues:orders:notify"), 1)
189
+
190
+ def test_pop_reserves_into_sorted_set_and_increments_attempts(self):
191
+ tr, fake = self._tr()
192
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1}, queue="orders")
193
+ tr.publish("orders", EnvelopeCodec.encode(env))
194
+
195
+ msg = tr.pop("orders", timeout=0)
196
+ self.assertIsNotNone(msg)
197
+ # ready list drained, reserved set holds exactly the in-flight job
198
+ self.assertEqual(fake.llen("queues:orders"), 0)
199
+ self.assertEqual(fake.zcard("queues:orders:reserved"), 1)
200
+ # attempts incremented on reserve, exactly like Laravel's pop Lua
201
+ self.assertEqual(EnvelopeCodec.decode(msg.body)["attempts"], 1)
202
+ # the ack handle IS the reserved member (so ZREM matches byte-for-byte)
203
+ self.assertIn(msg.handle, fake.zsets["queues:orders:reserved"])
204
+
205
+ def test_ack_removes_from_reserved_set(self):
206
+ tr, fake = self._tr()
207
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1}, queue="orders")
208
+ tr.publish("orders", EnvelopeCodec.encode(env))
209
+ msg = tr.pop("orders", timeout=0)
210
+ tr.ack(msg)
211
+ self.assertEqual(fake.zcard("queues:orders:reserved"), 0)
212
+
213
+ def test_ack_noop_on_empty_handle(self):
214
+ tr, fake = self._tr()
215
+ tr.ack(ReceivedMessage(body="", queue="orders", handle=None))
216
+ self.assertEqual(fake.zsets, {})
217
+
218
+ def test_crashed_worker_reservation_is_re_reserved_on_next_pop(self):
219
+ """A reserved job whose retry-after has lapsed migrates back and is re-served
220
+ — the reliable-queue guarantee that prevents message loss on worker crash."""
221
+ tr, fake = self._tr(retry_after=-1) # reserve already expired the instant it lands
222
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1}, queue="orders")
223
+ tr.publish("orders", EnvelopeCodec.encode(env))
224
+
225
+ first = tr.pop("orders", timeout=0) # reserves (expired-at-now deadline)
226
+ self.assertIsNotNone(first)
227
+ # worker "crashes" without ack; next pop migrates the lapsed reservation back
228
+ second = tr.pop("orders", timeout=0)
229
+ self.assertIsNotNone(second)
230
+ # attempts climbed again on the second reservation
231
+ self.assertEqual(EnvelopeCodec.decode(second.body)["attempts"], 2)
232
+
233
+ def test_release_returns_job_to_delayed_set(self):
234
+ tr, fake = self._tr()
235
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1}, queue="orders")
236
+ tr.publish("orders", EnvelopeCodec.encode(env))
237
+ msg = tr.pop("orders", timeout=0)
238
+ tr.release(msg, delay=0)
239
+ # left the reserved set, parked on the delayed set for retry
240
+ self.assertEqual(fake.zcard("queues:orders:reserved"), 0)
241
+ self.assertEqual(fake.zcard("queues:orders:delayed"), 1)
242
+
243
+ def test_release_noop_in_owned_mode(self):
244
+ tr, fake = self._tr()
245
+ # owned-mode transport: release is a no-op (handle present but not compat)
246
+ owned = RedisTransport("redis://", client=fake)
247
+ owned.release(ReceivedMessage(body="b", queue="orders", handle="b"))
248
+ self.assertEqual(fake.zsets, {})
249
+
250
+ def test_pop_empty_returns_none(self):
251
+ tr, _ = self._tr()
252
+ self.assertIsNone(tr.pop("orders", timeout=0))
253
+
254
+ def test_pop_block_window_drains_to_none(self):
255
+ """Empty ready list with a stale notify token: the block path consumes the
256
+ token (blpop wakeup), re-checks, finds nothing and returns None — the
257
+ timeout-drain case a consume loop relies on to make progress."""
258
+ tr, fake = self._tr(block_for=1)
259
+ fake.rpush("queues:orders:notify", "1") # token but no actual job
260
+ self.assertIsNone(tr.pop("orders", timeout=1))
261
+ # the stale token was consumed by the blpop wakeup
262
+ self.assertEqual(fake.llen("queues:orders:notify"), 0)
263
+
264
+ def test_pop_retrieves_when_job_already_ready(self):
265
+ """A job already on the ready list is reserved on the first eval (no block)."""
266
+ tr, fake = self._tr()
267
+ env = EnvelopeCodec.make("urn:babel:orders:created", {"x": 1}, queue="orders")
268
+ fake.rpush("queues:orders", EnvelopeCodec.encode(env))
269
+ fake.rpush("queues:orders:notify", "1")
270
+ msg = tr.pop("orders", timeout=1)
271
+ self.assertIsNotNone(msg)
272
+ self.assertEqual(EnvelopeCodec.decode(msg.body)["attempts"], 1)
273
+
274
+ def test_url_enables_compat_and_overrides(self):
275
+ fake = FakeRedis()
276
+ tr = RedisTransport("redis://h:6379/0?laravel=1&prefix=q:&retry_after=90", client=fake)
277
+ self.assertTrue(tr._laravel_compat)
278
+ self.assertEqual(tr._key_prefix, "q:")
279
+ self.assertEqual(tr._retry_after, 90)
280
+
281
+ def test_pop_result_handles_short_and_false_replies(self):
282
+ # empty / too-short reply, and Lua `false` (None) job or reserved member
283
+ self.assertEqual(_pop_result([]), (None, None))
284
+ self.assertEqual(_pop_result([None, None]), (None, None))
285
+ self.assertEqual(_pop_result(["job", "reserved"]), ("job", "reserved"))
286
+
287
+ def test_qint_falls_back_on_missing_and_non_numeric(self):
288
+ self.assertEqual(_qint({}, "retry_after", 60), 60) # absent -> default
289
+ self.assertEqual(_qint({"retry_after": ["nope"]}, "retry_after", 60), 60) # non-int
290
+ self.assertEqual(_qint({"retry_after": ["90"]}, "retry_after", 60), 90) # parsed
291
+
292
+ def test_round_trip_through_app(self):
293
+ fake = FakeRedis()
294
+ tr = RedisTransport("redis://", client=fake, laravel_compat=True)
295
+ app = BabelQueue(transport=tr, queue="orders")
296
+ seen: dict = {}
297
+
298
+ @app.handler("urn:babel:orders:created")
299
+ def _on(data, meta): # noqa: ANN001
300
+ seen.update({"data": data, "meta": meta})
301
+
302
+ msg_id = app.publish("urn:babel:orders:created", {"order_id": 7})
303
+ processed = app.consume("orders", max_messages=1, timeout=0)
304
+ self.assertEqual(processed, 1)
305
+ self.assertEqual(seen["data"]["order_id"], 7)
306
+ self.assertEqual(seen["meta"]["id"], msg_id)
307
+ # acked: reserved set empty
308
+ self.assertEqual(fake.zcard("queues:orders:reserved"), 0)
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Integration: skipped unless a real Redis is reachable (CI runs it).
313
+ # ---------------------------------------------------------------------------
314
+
315
+ try:
316
+ import redis as _redis
317
+ except ImportError: # pragma: no cover
318
+ _redis = None
319
+
320
+ REDIS_URL = os.environ.get("BABELQUEUE_TEST_REDIS", "redis://localhost:6379/0")
321
+
322
+
323
+ def _redis_available() -> bool:
324
+ if _redis is None:
325
+ return False
326
+ try:
327
+ _redis.Redis.from_url(REDIS_URL, decode_responses=True).ping()
328
+ return True
329
+ except Exception: # pragma: no cover - connection failure
330
+ return False
331
+
332
+
333
+ @unittest.skipUnless(_redis_available(), f"no reachable Redis at {REDIS_URL}")
334
+ class RedisTransportIntegrationTest(unittest.TestCase):
335
+ def setUp(self) -> None:
336
+ self.queue = f"bqtest:{uuid.uuid4().hex}"
337
+ self.client = _redis.Redis.from_url(REDIS_URL, decode_responses=True)
338
+
339
+ def tearDown(self) -> None:
340
+ self.client.delete(self.queue, f"{self.queue}:processing", f"{self.queue}.dlq")
341
+ self.client.delete(
342
+ f"queues:{self.queue}", f"queues:{self.queue}:reserved",
343
+ f"queues:{self.queue}:delayed", f"queues:{self.queue}:notify",
344
+ )
345
+
346
+ def test_publish_consume_round_trip_and_ack(self) -> None:
347
+ app = BabelQueue(REDIS_URL, queue=self.queue)
348
+ seen = {}
349
+
350
+ @app.handler("urn:babel:orders:created")
351
+ def handle(data, meta): # noqa: ANN001
352
+ seen.update(data)
353
+
354
+ app.publish("urn:babel:orders:created", {"order_id": 42})
355
+ processed = app.consume(max_messages=1, timeout=2)
356
+
357
+ self.assertEqual(processed, 1)
358
+ self.assertEqual(seen, {"order_id": 42})
359
+ self.assertEqual(self.client.llen(self.queue), 0)
360
+ self.assertEqual(self.client.llen(f"{self.queue}:processing"), 0)
361
+
362
+ def test_failure_dead_letters_to_redis(self) -> None:
363
+ app = BabelQueue(REDIS_URL, queue=self.queue, max_attempts=1, dead_letter=True)
364
+
365
+ @app.handler("urn:babel:orders:created")
366
+ def handle(data, meta): # noqa: ANN001
367
+ raise RuntimeError("boom")
368
+
369
+ app.publish("urn:babel:orders:created", {"order_id": 1})
370
+ app.consume(max_messages=2, timeout=2)
371
+
372
+ self.assertEqual(self.client.llen(f"{self.queue}:processing"), 0)
373
+ dlq = self.client.lrange(f"{self.queue}.dlq", 0, -1)
374
+ self.assertEqual(len(dlq), 1)
375
+ env = EnvelopeCodec.decode(dlq[0])
376
+ self.assertEqual(env["dead_letter"]["reason"], "failed")
377
+
378
+ def test_laravel_compat_reserved_set_round_trip(self) -> None:
379
+ """Against a real Redis: a Laravel-compatible worker reserves into the
380
+ ``queues:<name>:reserved`` sorted set and acks via ZREM."""
381
+ tr = RedisTransport(REDIS_URL, laravel_compat=True)
382
+ app = BabelQueue(transport=tr, queue=self.queue)
383
+ seen = {}
384
+ app.register("urn:babel:orders:created", lambda data, meta: seen.update(data))
385
+
386
+ app.publish("urn:babel:orders:created", {"order_id": 99})
387
+ processed = app.consume(self.queue, max_messages=1, timeout=2)
388
+
389
+ self.assertEqual(processed, 1)
390
+ self.assertEqual(seen, {"order_id": 99})
391
+ self.assertEqual(self.client.zcard(f"queues:{self.queue}:reserved"), 0)
392
+ self.assertEqual(self.client.llen(f"queues:{self.queue}"), 0)
393
+
394
+
395
+ if __name__ == "__main__":
396
+ unittest.main()
@@ -1,53 +0,0 @@
1
- """Redis transport (reliable-queue pattern). Requires the ``redis`` extra:
2
-
3
- pip install "babelqueue[redis]"
4
-
5
- Producing is ``RPUSH queue body``; consuming atomically moves the head to a
6
- per-queue processing list (``BLMOVE``) so an in-flight message survives a worker
7
- crash, and ``ack`` removes it from that processing list. This is a Python-owned
8
- reliable queue; full parity with Laravel's reserved-set reservation on a *shared*
9
- Redis queue is a separate conformance task (see the roadmap).
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- from typing import Optional
15
-
16
- from .transport import ReceivedMessage, Transport
17
-
18
-
19
- class RedisTransport(Transport):
20
- def __init__(self, url: str, *, processing_suffix: str = ":processing") -> None:
21
- try:
22
- import redis # noqa: F401 (lazy: only needed for this transport)
23
- except ImportError as exc: # pragma: no cover - import guard
24
- raise ImportError(
25
- "RedisTransport requires the 'redis' package. Install with "
26
- "pip install \"babelqueue[redis]\"."
27
- ) from exc
28
-
29
- self._redis = redis.Redis.from_url(url, decode_responses=True)
30
- self._processing_suffix = processing_suffix
31
-
32
- def _processing(self, queue: str) -> str:
33
- return f"{queue}{self._processing_suffix}"
34
-
35
- def publish(self, queue: str, body: str) -> None:
36
- self._redis.rpush(queue, body)
37
-
38
- def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
39
- # redis-py types the BLMOVE timeout as int, but Redis accepts a float
40
- # (sub-second) timeout; passing it through is correct at runtime.
41
- body = self._redis.blmove(queue, self._processing(queue), timeout, "LEFT", "RIGHT") # type: ignore[arg-type]
42
- if body is None:
43
- return None
44
- # decode_responses=True yields str; the guard satisfies the type checker
45
- # (and is a harmless safety net otherwise).
46
- text = body if isinstance(body, str) else body.decode()
47
- return ReceivedMessage(body=text, queue=queue, handle=text)
48
-
49
- def ack(self, message: ReceivedMessage) -> None:
50
- self._redis.lrem(self._processing(message.queue), 1, message.handle)
51
-
52
- def close(self) -> None: # pragma: no cover
53
- self._redis.close()
@@ -1,78 +0,0 @@
1
- """Integration tests for the Redis transport.
2
-
3
- Skipped unless a Redis server is reachable (the `redis` package installed and a
4
- broker at ``BABELQUEUE_TEST_REDIS`` / localhost). The CI ``integration`` job runs
5
- these against a Redis service; locally they skip cleanly.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import os
11
- import unittest
12
- import uuid
13
-
14
- try:
15
- import redis as _redis
16
- except ImportError: # pragma: no cover
17
- _redis = None
18
-
19
- from babelqueue import BabelQueue, EnvelopeCodec
20
-
21
- REDIS_URL = os.environ.get("BABELQUEUE_TEST_REDIS", "redis://localhost:6379/0")
22
-
23
-
24
- def _redis_available() -> bool:
25
- if _redis is None:
26
- return False
27
- try:
28
- _redis.Redis.from_url(REDIS_URL, decode_responses=True).ping()
29
- return True
30
- except Exception: # pragma: no cover - connection failure
31
- return False
32
-
33
-
34
- @unittest.skipUnless(_redis_available(), f"no reachable Redis at {REDIS_URL}")
35
- class RedisTransportTest(unittest.TestCase):
36
- def setUp(self) -> None:
37
- self.queue = f"bqtest:{uuid.uuid4().hex}"
38
- self.client = _redis.Redis.from_url(REDIS_URL, decode_responses=True)
39
-
40
- def tearDown(self) -> None:
41
- self.client.delete(self.queue, f"{self.queue}:processing", f"{self.queue}.dlq")
42
-
43
- def test_publish_consume_round_trip_and_ack(self) -> None:
44
- app = BabelQueue(REDIS_URL, queue=self.queue)
45
- seen = {}
46
-
47
- @app.handler("urn:babel:orders:created")
48
- def handle(data, meta): # noqa: ANN001
49
- seen.update(data)
50
-
51
- app.publish("urn:babel:orders:created", {"order_id": 42})
52
- processed = app.consume(max_messages=1, timeout=2)
53
-
54
- self.assertEqual(processed, 1)
55
- self.assertEqual(seen, {"order_id": 42})
56
- # acked: nothing left on the queue or the processing list
57
- self.assertEqual(self.client.llen(self.queue), 0)
58
- self.assertEqual(self.client.llen(f"{self.queue}:processing"), 0)
59
-
60
- def test_failure_dead_letters_to_redis(self) -> None:
61
- app = BabelQueue(REDIS_URL, queue=self.queue, max_attempts=1, dead_letter=True)
62
-
63
- @app.handler("urn:babel:orders:created")
64
- def handle(data, meta): # noqa: ANN001
65
- raise RuntimeError("boom")
66
-
67
- app.publish("urn:babel:orders:created", {"order_id": 1})
68
- app.consume(max_messages=2, timeout=2)
69
-
70
- self.assertEqual(self.client.llen(f"{self.queue}:processing"), 0)
71
- dlq = self.client.lrange(f"{self.queue}.dlq", 0, -1)
72
- self.assertEqual(len(dlq), 1)
73
- env = EnvelopeCodec.decode(dlq[0])
74
- self.assertEqual(env["dead_letter"]["reason"], "failed")
75
-
76
-
77
- if __name__ == "__main__":
78
- unittest.main()
File without changes
File without changes
File without changes