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.
- {babelqueue-1.5.0 → babelqueue-1.6.0}/CHANGELOG.md +28 -1
- {babelqueue-1.5.0 → babelqueue-1.6.0}/PKG-INFO +30 -4
- {babelqueue-1.5.0 → babelqueue-1.6.0}/README.md +29 -3
- {babelqueue-1.5.0 → babelqueue-1.6.0}/pyproject.toml +1 -1
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/__init__.py +1 -1
- babelqueue-1.6.0/src/babelqueue/redis_transport.py +254 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/manifest.json +26 -0
- babelqueue-1.6.0/tests/test_redis_transport.py +396 -0
- babelqueue-1.5.0/src/babelqueue/redis_transport.py +0 -53
- babelqueue-1.5.0/tests/test_redis_transport.py +0 -78
- {babelqueue-1.5.0 → babelqueue-1.6.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/.gitignore +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/LICENSE +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_app.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_celery.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_codec.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_django.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.5.0 → babelqueue-1.6.0}/tests/test_sqs_conformance.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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.5.0 → babelqueue-1.6.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.5.0 → babelqueue-1.6.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
|