redis-message-queue 8.0.2__tar.gz → 8.1.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.
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/PKG-INFO +131 -4
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/README.md +130 -3
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/pyproject.toml +1 -1
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/__init__.py +4 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_config.py +3 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_event.py +6 -0
- redis_message_queue-8.1.0/redis_message_queue/_exceptions.py +149 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_queue_key_manager.py +8 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_redis_cluster.py +25 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_redis_gateway.py +81 -16
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_stored_message.py +16 -7
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/__init__.py +8 -1
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/_redis_gateway.py +81 -16
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/redis_message_queue.py +304 -16
- redis_message_queue-8.1.0/redis_message_queue/interrupt_handler/__init__.py +9 -0
- redis_message_queue-8.1.0/redis_message_queue/interrupt_handler/_event_driven.py +24 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/interrupt_handler/_implementation.py +6 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/redis_message_queue.py +301 -13
- redis_message_queue-8.0.2/redis_message_queue/_exceptions.py +0 -71
- redis_message_queue-8.0.2/redis_message_queue/interrupt_handler/__init__.py +0 -4
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/LICENSE +0 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redis-message-queue
|
|
3
|
-
Version: 8.0
|
|
3
|
+
Version: 8.1.0
|
|
4
4
|
Summary: Python message queuing with Redis and message deduplication
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -68,6 +68,13 @@ with queue.process_message() as message:
|
|
|
68
68
|
`RedisMessageQueue` itself is not a context manager. Use
|
|
69
69
|
`with queue.process_message() as message:` for each message.
|
|
70
70
|
|
|
71
|
+
> **Important:** In the sync API, work inside `process_message()` must be
|
|
72
|
+
> synchronous. If your handler is `async def`, returns a coroutine, or returns
|
|
73
|
+
> any other awaitable, use `redis_message_queue.asyncio.RedisMessageQueue`.
|
|
74
|
+
> The sync context manager does not inspect the handler's return value; an
|
|
75
|
+
> unawaited coroutine can be dropped while the message is acked. An ergonomic
|
|
76
|
+
> callback API that detects this is planned for v8.1.
|
|
77
|
+
|
|
71
78
|
### Async quickstart
|
|
72
79
|
|
|
73
80
|
```python
|
|
@@ -119,6 +126,12 @@ All features are optional and can be enabled or disabled as needed.
|
|
|
119
126
|
|
|
120
127
|
See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout) for details and tradeoffs.
|
|
121
128
|
|
|
129
|
+
> **Important:** Handler exceptions are terminal. This library is a payload
|
|
130
|
+
> queue, not a task framework: raising inside `process_message()` does not
|
|
131
|
+
> requeue the message. With `enable_failed_queue=False`, the message is removed
|
|
132
|
+
> from `processing`; with `enable_failed_queue=True`, it is moved to the failed
|
|
133
|
+
> list.
|
|
134
|
+
|
|
122
135
|
## Configuration
|
|
123
136
|
|
|
124
137
|
### Deduplication
|
|
@@ -159,6 +172,13 @@ Avoid fallback patterns such as `lambda msg: msg.get("order_id", "")`.
|
|
|
159
172
|
Missing fields should fail loudly instead of collapsing unrelated messages into
|
|
160
173
|
one deduplication key.
|
|
161
174
|
|
|
175
|
+
Deduplication markers and publish retry-safety markers are Redis TTL keys. A
|
|
176
|
+
large forward step in the Redis server expiration clock during an in-call retry
|
|
177
|
+
window can expire those markers before the Python-side monotonic retry budget
|
|
178
|
+
elapses, allowing a duplicate publish. This is an extreme anomaly, mainly
|
|
179
|
+
relevant under cluster-wide NTP step corrections while a producer is retrying
|
|
180
|
+
after an ambiguous Redis write.
|
|
181
|
+
|
|
162
182
|
### Success and failure tracking
|
|
163
183
|
|
|
164
184
|
```python
|
|
@@ -234,6 +254,11 @@ queue = RedisMessageQueue(
|
|
|
234
254
|
)
|
|
235
255
|
```
|
|
236
256
|
|
|
257
|
+
> **Important:** `visibility_timeout_seconds` is a lease, not a handler runtime
|
|
258
|
+
> cap. rmq never interrupts a long-running handler. If the lease expires while
|
|
259
|
+
> the handler continues, another consumer can reclaim and process the same
|
|
260
|
+
> message concurrently.
|
|
261
|
+
|
|
237
262
|
This enables lease-based redelivery for messages left in `processing` by a crashed worker and renews the lease while a healthy long-running handler is still working.
|
|
238
263
|
Tradeoffs:
|
|
239
264
|
- delivery becomes at-least-once after lease expiry
|
|
@@ -258,6 +283,13 @@ The callback is **advisory** — it may fire briefly after a successful `process
|
|
|
258
283
|
|
|
259
284
|
Without a visibility timeout, messages already moved to `processing` remain there indefinitely after a consumer crash and are not redelivered, even if the crash happened before your handler started running.
|
|
260
285
|
|
|
286
|
+
Visibility deadlines use Redis server time (`TIME`), not Python process time.
|
|
287
|
+
A forward step in the Redis server clock can make a live lease appear expired
|
|
288
|
+
and allow premature redelivery while the original consumer is still processing;
|
|
289
|
+
a backward step can delay reclaim of truly abandoned messages. Treat NTP step
|
|
290
|
+
corrections on Redis hosts as a deployment risk. Prefer time-synchronization
|
|
291
|
+
discipline that slews corrections rather than stepping the Redis clock.
|
|
292
|
+
|
|
261
293
|
### Ordering and multi-consumer fairness
|
|
262
294
|
|
|
263
295
|
The built-in queue is a shared-pull Redis list. Successful publishes push to the
|
|
@@ -354,6 +386,37 @@ while not interrupt.is_interrupted():
|
|
|
354
386
|
> `ValueError`. A repeated owned signal falls back to the default behavior
|
|
355
387
|
> (for example, a second Ctrl+C raises `KeyboardInterrupt`). If you need multiple
|
|
356
388
|
> shutdown hooks, use a single handler and fan out in your own code.
|
|
389
|
+
>
|
|
390
|
+
> Process-global signal ownership cannot be safely chained with task-worker
|
|
391
|
+
> CLIs such as Celery, RQ, or Dramatiq. Run sibling workers in separate
|
|
392
|
+
> processes, or install one top-level signal owner that calls `queue.drain()`
|
|
393
|
+
> / `queue.aclose()` or sets an application stop event.
|
|
394
|
+
|
|
395
|
+
If another library owns SIGTERM/SIGINT in the same process, adapt its shutdown
|
|
396
|
+
signal to rmq with a user-owned event instead of installing rmq signal handlers:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
import threading
|
|
400
|
+
|
|
401
|
+
from redis_message_queue import EventDrivenInterruptHandler, RedisMessageQueue
|
|
402
|
+
|
|
403
|
+
stop_event = threading.Event()
|
|
404
|
+
interrupt = EventDrivenInterruptHandler(stop_event)
|
|
405
|
+
queue = RedisMessageQueue("q", client=client, interrupt=interrupt)
|
|
406
|
+
|
|
407
|
+
while not interrupt.is_interrupted():
|
|
408
|
+
with queue.process_message() as message:
|
|
409
|
+
if message is not None:
|
|
410
|
+
process(message)
|
|
411
|
+
|
|
412
|
+
# In the sibling library's shutdown hook:
|
|
413
|
+
stop_event.set()
|
|
414
|
+
queue.drain(timeout=25)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The caller MUST set `stop_event` before exiting. rmq observes
|
|
418
|
+
`is_interrupted()` and exits cooperatively; it does not call `sys.exit()` or
|
|
419
|
+
otherwise force process shutdown.
|
|
357
420
|
|
|
358
421
|
There are three distinct shutdown shapes; pick the one that matches your runtime:
|
|
359
422
|
|
|
@@ -383,7 +446,10 @@ if a publish is already inside the queue instance's publish path, drain waits
|
|
|
383
446
|
for that publish to finish before it returns; publishes that arrive after the
|
|
384
447
|
drained flag is set are rejected. The drained state is local to that Python
|
|
385
448
|
queue object and is not written to Redis, so constructing a fresh
|
|
386
|
-
`RedisMessageQueue(...)` over the same keys remains usable.
|
|
449
|
+
`RedisMessageQueue(...)` over the same keys remains usable. A separate process
|
|
450
|
+
or separate queue instance against the same Redis keys is not marked drained by
|
|
451
|
+
this call. For multi-process graceful shutdown, each process must drain its own
|
|
452
|
+
queue instances.
|
|
387
453
|
|
|
388
454
|
Drain does not cancel in-flight handlers — the caller must arrange handler
|
|
389
455
|
exit through normal thread/task coordination. Returns `True` if all in-memory
|
|
@@ -391,6 +457,24 @@ pending claim IDs were recovered within the timeout; `False` if the deadline
|
|
|
391
457
|
fired or transient Redis errors left claim IDs pending (call again to retry).
|
|
392
458
|
`timeout=0` reports current state without attempting recovery.
|
|
393
459
|
|
|
460
|
+
#### Abandoned in-flight messages
|
|
461
|
+
|
|
462
|
+
Abandoned in-flight messages are recovered lazily. Async tasks cancelled
|
|
463
|
+
without `aclose()`, or sync processes killed mid-handler, can leave the message
|
|
464
|
+
and its processing/lease metadata in Redis until a later consumer claim path
|
|
465
|
+
triggers visibility-timeout reclaim. With visibility timeouts enabled, this is
|
|
466
|
+
the designed at-least-once recovery path: the message is delayed by the lease,
|
|
467
|
+
not lost. With `visibility_timeout_seconds=None`, there is no automatic reclaim
|
|
468
|
+
path. For low-visibility-timeout workloads, prefer an explicit `drain()` /
|
|
469
|
+
`aclose()` during shutdown so local pending claim IDs are recovered before
|
|
470
|
+
process exit.
|
|
471
|
+
|
|
472
|
+
`drain()` / `aclose()` timeouts are measured with Python monotonic clocks, but
|
|
473
|
+
any lease deadlines they recover were created from Redis server time. The same
|
|
474
|
+
Redis-clock step caveats from
|
|
475
|
+
[Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout)
|
|
476
|
+
apply to when abandoned work becomes reclaimable.
|
|
477
|
+
|
|
394
478
|
> **Heartbeat caveat (best-effort stop):** when `heartbeat_interval_seconds` is
|
|
395
479
|
> set, the heartbeat sidecar's `stop()` is bounded but not strictly quiescent —
|
|
396
480
|
> a slow renewal in flight when `process_message` exits may still write to
|
|
@@ -495,6 +579,47 @@ await client.aclose()
|
|
|
495
579
|
For the sync Redis client, call `client.close()` during application shutdown when
|
|
496
580
|
you own the client lifecycle.
|
|
497
581
|
|
|
582
|
+
## Migrating from RQ / Celery / Dramatiq / taskiq
|
|
583
|
+
|
|
584
|
+
redis-message-queue is a payload queue, not a task framework. It has no task
|
|
585
|
+
registry, job object, result backend, scheduler, workflow canvas, callback
|
|
586
|
+
graph, or handler-level retry policy. Producers publish a `str` or `dict`
|
|
587
|
+
payload, and consumers decide what that payload means.
|
|
588
|
+
|
|
589
|
+
The most important semantic differences from sibling task libraries are:
|
|
590
|
+
|
|
591
|
+
- Handler exceptions are terminal. Raising inside `process_message()` removes
|
|
592
|
+
the message from `processing`, or moves it to the failed list when
|
|
593
|
+
`enable_failed_queue=True`; it does not requeue or retry the message.
|
|
594
|
+
- `visibility_timeout_seconds` is a crash/stall recovery lease, not a runtime
|
|
595
|
+
limit. Slow handlers are not interrupted; after the lease expires another
|
|
596
|
+
consumer can process the same payload concurrently.
|
|
597
|
+
- `on_event` is telemetry only. Callback exceptions are logged and emitted as
|
|
598
|
+
`RuntimeWarning`, but they do not affect ack/nack, failed-queue movement, or
|
|
599
|
+
any other message outcome. Do not use `on_event` for sagas, follow-up writes,
|
|
600
|
+
billing callbacks, or other correctness-critical work.
|
|
601
|
+
- Dict payloads are JSON data, not Python call arguments. JSON does not
|
|
602
|
+
preserve every Python type: tuples become lists, and sets or custom objects
|
|
603
|
+
raise unless you encode them into JSON-native values first.
|
|
604
|
+
- Process-global signal ownership cannot be safely chained with Celery, RQ, or
|
|
605
|
+
Dramatiq CLI workers. Prefer one top-level owner that calls `queue.drain()`
|
|
606
|
+
or sets an application stop event, and run sibling workers in separate
|
|
607
|
+
processes.
|
|
608
|
+
|
|
609
|
+
When migrating on the same Redis deployment, prefer separate Redis DBs or hard
|
|
610
|
+
namespaces. Do not point a Celery, RQ, Dramatiq, or taskiq worker at an rmq
|
|
611
|
+
pending key. A sibling worker can pop the rmq stored message, fail its own
|
|
612
|
+
decoder, and leave the rmq queue without that message. Also avoid custom
|
|
613
|
+
`key_separator` values that synthesize another library's key namespace, such as
|
|
614
|
+
using `":queue:"` with a queue name that overlaps RQ keys. rmq has no fixed
|
|
615
|
+
library prefix; generated keys share the Redis DB namespace with every other
|
|
616
|
+
Redis user.
|
|
617
|
+
|
|
618
|
+
Set `strict_envelope_decoding=True` if this Redis is shared with sibling task
|
|
619
|
+
libraries (Celery, RQ, Dramatiq) to fail-fast on foreign payloads. With the
|
|
620
|
+
default `False`, non-rmq values that do not start with the rmq envelope prefix
|
|
621
|
+
remain backward-compatible raw messages and are yielded to the handler.
|
|
622
|
+
|
|
498
623
|
## Production notes
|
|
499
624
|
|
|
500
625
|
### Fork safety and pre-fork servers
|
|
@@ -610,8 +735,10 @@ Events cover publish, dedup hits, claim/empty polls, reclaim, ack/nack,
|
|
|
610
735
|
completed/failed cleanup, DLQ moves, heartbeat renewal, stale leases, cleanup
|
|
611
736
|
and trim failures, and retry attempts. Callback exceptions are logged and
|
|
612
737
|
reported with `RuntimeWarning`, but never propagate into queue operations.
|
|
613
|
-
|
|
614
|
-
|
|
738
|
+
`on_event` is telemetry only: use it for metrics, tracing, and logging, not for
|
|
739
|
+
sagas, follow-up writes, billing callbacks, or other correctness-critical
|
|
740
|
+
work. Package logs remain diagnostic; use `on_event` rather than log parsing
|
|
741
|
+
for metrics.
|
|
615
742
|
|
|
616
743
|
```python
|
|
617
744
|
from opentelemetry import trace
|
|
@@ -42,6 +42,13 @@ with queue.process_message() as message:
|
|
|
42
42
|
`RedisMessageQueue` itself is not a context manager. Use
|
|
43
43
|
`with queue.process_message() as message:` for each message.
|
|
44
44
|
|
|
45
|
+
> **Important:** In the sync API, work inside `process_message()` must be
|
|
46
|
+
> synchronous. If your handler is `async def`, returns a coroutine, or returns
|
|
47
|
+
> any other awaitable, use `redis_message_queue.asyncio.RedisMessageQueue`.
|
|
48
|
+
> The sync context manager does not inspect the handler's return value; an
|
|
49
|
+
> unawaited coroutine can be dropped while the message is acked. An ergonomic
|
|
50
|
+
> callback API that detects this is planned for v8.1.
|
|
51
|
+
|
|
45
52
|
### Async quickstart
|
|
46
53
|
|
|
47
54
|
```python
|
|
@@ -93,6 +100,12 @@ All features are optional and can be enabled or disabled as needed.
|
|
|
93
100
|
|
|
94
101
|
See [Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout) for details and tradeoffs.
|
|
95
102
|
|
|
103
|
+
> **Important:** Handler exceptions are terminal. This library is a payload
|
|
104
|
+
> queue, not a task framework: raising inside `process_message()` does not
|
|
105
|
+
> requeue the message. With `enable_failed_queue=False`, the message is removed
|
|
106
|
+
> from `processing`; with `enable_failed_queue=True`, it is moved to the failed
|
|
107
|
+
> list.
|
|
108
|
+
|
|
96
109
|
## Configuration
|
|
97
110
|
|
|
98
111
|
### Deduplication
|
|
@@ -133,6 +146,13 @@ Avoid fallback patterns such as `lambda msg: msg.get("order_id", "")`.
|
|
|
133
146
|
Missing fields should fail loudly instead of collapsing unrelated messages into
|
|
134
147
|
one deduplication key.
|
|
135
148
|
|
|
149
|
+
Deduplication markers and publish retry-safety markers are Redis TTL keys. A
|
|
150
|
+
large forward step in the Redis server expiration clock during an in-call retry
|
|
151
|
+
window can expire those markers before the Python-side monotonic retry budget
|
|
152
|
+
elapses, allowing a duplicate publish. This is an extreme anomaly, mainly
|
|
153
|
+
relevant under cluster-wide NTP step corrections while a producer is retrying
|
|
154
|
+
after an ambiguous Redis write.
|
|
155
|
+
|
|
136
156
|
### Success and failure tracking
|
|
137
157
|
|
|
138
158
|
```python
|
|
@@ -208,6 +228,11 @@ queue = RedisMessageQueue(
|
|
|
208
228
|
)
|
|
209
229
|
```
|
|
210
230
|
|
|
231
|
+
> **Important:** `visibility_timeout_seconds` is a lease, not a handler runtime
|
|
232
|
+
> cap. rmq never interrupts a long-running handler. If the lease expires while
|
|
233
|
+
> the handler continues, another consumer can reclaim and process the same
|
|
234
|
+
> message concurrently.
|
|
235
|
+
|
|
211
236
|
This enables lease-based redelivery for messages left in `processing` by a crashed worker and renews the lease while a healthy long-running handler is still working.
|
|
212
237
|
Tradeoffs:
|
|
213
238
|
- delivery becomes at-least-once after lease expiry
|
|
@@ -232,6 +257,13 @@ The callback is **advisory** — it may fire briefly after a successful `process
|
|
|
232
257
|
|
|
233
258
|
Without a visibility timeout, messages already moved to `processing` remain there indefinitely after a consumer crash and are not redelivered, even if the crash happened before your handler started running.
|
|
234
259
|
|
|
260
|
+
Visibility deadlines use Redis server time (`TIME`), not Python process time.
|
|
261
|
+
A forward step in the Redis server clock can make a live lease appear expired
|
|
262
|
+
and allow premature redelivery while the original consumer is still processing;
|
|
263
|
+
a backward step can delay reclaim of truly abandoned messages. Treat NTP step
|
|
264
|
+
corrections on Redis hosts as a deployment risk. Prefer time-synchronization
|
|
265
|
+
discipline that slews corrections rather than stepping the Redis clock.
|
|
266
|
+
|
|
235
267
|
### Ordering and multi-consumer fairness
|
|
236
268
|
|
|
237
269
|
The built-in queue is a shared-pull Redis list. Successful publishes push to the
|
|
@@ -328,6 +360,37 @@ while not interrupt.is_interrupted():
|
|
|
328
360
|
> `ValueError`. A repeated owned signal falls back to the default behavior
|
|
329
361
|
> (for example, a second Ctrl+C raises `KeyboardInterrupt`). If you need multiple
|
|
330
362
|
> shutdown hooks, use a single handler and fan out in your own code.
|
|
363
|
+
>
|
|
364
|
+
> Process-global signal ownership cannot be safely chained with task-worker
|
|
365
|
+
> CLIs such as Celery, RQ, or Dramatiq. Run sibling workers in separate
|
|
366
|
+
> processes, or install one top-level signal owner that calls `queue.drain()`
|
|
367
|
+
> / `queue.aclose()` or sets an application stop event.
|
|
368
|
+
|
|
369
|
+
If another library owns SIGTERM/SIGINT in the same process, adapt its shutdown
|
|
370
|
+
signal to rmq with a user-owned event instead of installing rmq signal handlers:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
import threading
|
|
374
|
+
|
|
375
|
+
from redis_message_queue import EventDrivenInterruptHandler, RedisMessageQueue
|
|
376
|
+
|
|
377
|
+
stop_event = threading.Event()
|
|
378
|
+
interrupt = EventDrivenInterruptHandler(stop_event)
|
|
379
|
+
queue = RedisMessageQueue("q", client=client, interrupt=interrupt)
|
|
380
|
+
|
|
381
|
+
while not interrupt.is_interrupted():
|
|
382
|
+
with queue.process_message() as message:
|
|
383
|
+
if message is not None:
|
|
384
|
+
process(message)
|
|
385
|
+
|
|
386
|
+
# In the sibling library's shutdown hook:
|
|
387
|
+
stop_event.set()
|
|
388
|
+
queue.drain(timeout=25)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The caller MUST set `stop_event` before exiting. rmq observes
|
|
392
|
+
`is_interrupted()` and exits cooperatively; it does not call `sys.exit()` or
|
|
393
|
+
otherwise force process shutdown.
|
|
331
394
|
|
|
332
395
|
There are three distinct shutdown shapes; pick the one that matches your runtime:
|
|
333
396
|
|
|
@@ -357,7 +420,10 @@ if a publish is already inside the queue instance's publish path, drain waits
|
|
|
357
420
|
for that publish to finish before it returns; publishes that arrive after the
|
|
358
421
|
drained flag is set are rejected. The drained state is local to that Python
|
|
359
422
|
queue object and is not written to Redis, so constructing a fresh
|
|
360
|
-
`RedisMessageQueue(...)` over the same keys remains usable.
|
|
423
|
+
`RedisMessageQueue(...)` over the same keys remains usable. A separate process
|
|
424
|
+
or separate queue instance against the same Redis keys is not marked drained by
|
|
425
|
+
this call. For multi-process graceful shutdown, each process must drain its own
|
|
426
|
+
queue instances.
|
|
361
427
|
|
|
362
428
|
Drain does not cancel in-flight handlers — the caller must arrange handler
|
|
363
429
|
exit through normal thread/task coordination. Returns `True` if all in-memory
|
|
@@ -365,6 +431,24 @@ pending claim IDs were recovered within the timeout; `False` if the deadline
|
|
|
365
431
|
fired or transient Redis errors left claim IDs pending (call again to retry).
|
|
366
432
|
`timeout=0` reports current state without attempting recovery.
|
|
367
433
|
|
|
434
|
+
#### Abandoned in-flight messages
|
|
435
|
+
|
|
436
|
+
Abandoned in-flight messages are recovered lazily. Async tasks cancelled
|
|
437
|
+
without `aclose()`, or sync processes killed mid-handler, can leave the message
|
|
438
|
+
and its processing/lease metadata in Redis until a later consumer claim path
|
|
439
|
+
triggers visibility-timeout reclaim. With visibility timeouts enabled, this is
|
|
440
|
+
the designed at-least-once recovery path: the message is delayed by the lease,
|
|
441
|
+
not lost. With `visibility_timeout_seconds=None`, there is no automatic reclaim
|
|
442
|
+
path. For low-visibility-timeout workloads, prefer an explicit `drain()` /
|
|
443
|
+
`aclose()` during shutdown so local pending claim IDs are recovered before
|
|
444
|
+
process exit.
|
|
445
|
+
|
|
446
|
+
`drain()` / `aclose()` timeouts are measured with Python monotonic clocks, but
|
|
447
|
+
any lease deadlines they recover were created from Redis server time. The same
|
|
448
|
+
Redis-clock step caveats from
|
|
449
|
+
[Crash recovery with visibility timeout](#crash-recovery-with-visibility-timeout)
|
|
450
|
+
apply to when abandoned work becomes reclaimable.
|
|
451
|
+
|
|
368
452
|
> **Heartbeat caveat (best-effort stop):** when `heartbeat_interval_seconds` is
|
|
369
453
|
> set, the heartbeat sidecar's `stop()` is bounded but not strictly quiescent —
|
|
370
454
|
> a slow renewal in flight when `process_message` exits may still write to
|
|
@@ -469,6 +553,47 @@ await client.aclose()
|
|
|
469
553
|
For the sync Redis client, call `client.close()` during application shutdown when
|
|
470
554
|
you own the client lifecycle.
|
|
471
555
|
|
|
556
|
+
## Migrating from RQ / Celery / Dramatiq / taskiq
|
|
557
|
+
|
|
558
|
+
redis-message-queue is a payload queue, not a task framework. It has no task
|
|
559
|
+
registry, job object, result backend, scheduler, workflow canvas, callback
|
|
560
|
+
graph, or handler-level retry policy. Producers publish a `str` or `dict`
|
|
561
|
+
payload, and consumers decide what that payload means.
|
|
562
|
+
|
|
563
|
+
The most important semantic differences from sibling task libraries are:
|
|
564
|
+
|
|
565
|
+
- Handler exceptions are terminal. Raising inside `process_message()` removes
|
|
566
|
+
the message from `processing`, or moves it to the failed list when
|
|
567
|
+
`enable_failed_queue=True`; it does not requeue or retry the message.
|
|
568
|
+
- `visibility_timeout_seconds` is a crash/stall recovery lease, not a runtime
|
|
569
|
+
limit. Slow handlers are not interrupted; after the lease expires another
|
|
570
|
+
consumer can process the same payload concurrently.
|
|
571
|
+
- `on_event` is telemetry only. Callback exceptions are logged and emitted as
|
|
572
|
+
`RuntimeWarning`, but they do not affect ack/nack, failed-queue movement, or
|
|
573
|
+
any other message outcome. Do not use `on_event` for sagas, follow-up writes,
|
|
574
|
+
billing callbacks, or other correctness-critical work.
|
|
575
|
+
- Dict payloads are JSON data, not Python call arguments. JSON does not
|
|
576
|
+
preserve every Python type: tuples become lists, and sets or custom objects
|
|
577
|
+
raise unless you encode them into JSON-native values first.
|
|
578
|
+
- Process-global signal ownership cannot be safely chained with Celery, RQ, or
|
|
579
|
+
Dramatiq CLI workers. Prefer one top-level owner that calls `queue.drain()`
|
|
580
|
+
or sets an application stop event, and run sibling workers in separate
|
|
581
|
+
processes.
|
|
582
|
+
|
|
583
|
+
When migrating on the same Redis deployment, prefer separate Redis DBs or hard
|
|
584
|
+
namespaces. Do not point a Celery, RQ, Dramatiq, or taskiq worker at an rmq
|
|
585
|
+
pending key. A sibling worker can pop the rmq stored message, fail its own
|
|
586
|
+
decoder, and leave the rmq queue without that message. Also avoid custom
|
|
587
|
+
`key_separator` values that synthesize another library's key namespace, such as
|
|
588
|
+
using `":queue:"` with a queue name that overlaps RQ keys. rmq has no fixed
|
|
589
|
+
library prefix; generated keys share the Redis DB namespace with every other
|
|
590
|
+
Redis user.
|
|
591
|
+
|
|
592
|
+
Set `strict_envelope_decoding=True` if this Redis is shared with sibling task
|
|
593
|
+
libraries (Celery, RQ, Dramatiq) to fail-fast on foreign payloads. With the
|
|
594
|
+
default `False`, non-rmq values that do not start with the rmq envelope prefix
|
|
595
|
+
remain backward-compatible raw messages and are yielded to the handler.
|
|
596
|
+
|
|
472
597
|
## Production notes
|
|
473
598
|
|
|
474
599
|
### Fork safety and pre-fork servers
|
|
@@ -584,8 +709,10 @@ Events cover publish, dedup hits, claim/empty polls, reclaim, ack/nack,
|
|
|
584
709
|
completed/failed cleanup, DLQ moves, heartbeat renewal, stale leases, cleanup
|
|
585
710
|
and trim failures, and retry attempts. Callback exceptions are logged and
|
|
586
711
|
reported with `RuntimeWarning`, but never propagate into queue operations.
|
|
587
|
-
|
|
588
|
-
|
|
712
|
+
`on_event` is telemetry only: use it for metrics, tracing, and logging, not for
|
|
713
|
+
sagas, follow-up writes, billing callbacks, or other correctness-critical
|
|
714
|
+
work. Package logs remain diagnostic; use `on_event` rather than log parsing
|
|
715
|
+
for metrics.
|
|
589
716
|
|
|
590
717
|
```python
|
|
591
718
|
from opentelemetry import trace
|
|
@@ -3,6 +3,7 @@ from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
|
|
|
3
3
|
from redis_message_queue._exceptions import (
|
|
4
4
|
CleanupFailedError,
|
|
5
5
|
ConfigurationError,
|
|
6
|
+
DrainFailedError,
|
|
6
7
|
GatewayContractError,
|
|
7
8
|
LuaScriptError,
|
|
8
9
|
MalformedStoredMessageError,
|
|
@@ -15,6 +16,7 @@ from redis_message_queue._redis_gateway import RedisGateway
|
|
|
15
16
|
from redis_message_queue._stored_message import ClaimedMessage, MessageData
|
|
16
17
|
from redis_message_queue.interrupt_handler import (
|
|
17
18
|
BaseGracefulInterruptHandler,
|
|
19
|
+
EventDrivenInterruptHandler,
|
|
18
20
|
GracefulInterruptHandler,
|
|
19
21
|
)
|
|
20
22
|
from redis_message_queue.redis_message_queue import RedisMessageQueue
|
|
@@ -25,6 +27,7 @@ __all__ = [
|
|
|
25
27
|
"AbstractRedisGateway",
|
|
26
28
|
"ClaimedMessage",
|
|
27
29
|
"MessageData",
|
|
30
|
+
"EventDrivenInterruptHandler",
|
|
28
31
|
"GracefulInterruptHandler",
|
|
29
32
|
"BaseGracefulInterruptHandler",
|
|
30
33
|
"QueueEvent",
|
|
@@ -32,6 +35,7 @@ __all__ = [
|
|
|
32
35
|
"EventOutcome",
|
|
33
36
|
"RedisMessageQueueError",
|
|
34
37
|
"ConfigurationError",
|
|
38
|
+
"DrainFailedError",
|
|
35
39
|
"GatewayContractError",
|
|
36
40
|
"LuaScriptError",
|
|
37
41
|
"MalformedStoredMessageError",
|
|
@@ -50,6 +50,9 @@ def is_redis_retryable_exception(exception):
|
|
|
50
50
|
),
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
+
if isinstance(exception, redis.exceptions.ClusterError) and "TTL exhausted" in str(exception):
|
|
54
|
+
return True
|
|
55
|
+
|
|
53
56
|
# 2. Explicit retryable exceptions (BusyLoadingError is a ConnectionError
|
|
54
57
|
# subclass, so it is already handled by branch 1 above)
|
|
55
58
|
return isinstance(
|
|
@@ -24,6 +24,7 @@ class EventOperation(StrEnum):
|
|
|
24
24
|
TRIM_FAILED = "trim_failed"
|
|
25
25
|
RETRY_ATTEMPT = "retry_attempt"
|
|
26
26
|
RETRY_EXHAUSTED = "retry_exhausted"
|
|
27
|
+
DRAIN = "drain"
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
class EventOutcome(StrEnum):
|
|
@@ -32,6 +33,7 @@ class EventOutcome(StrEnum):
|
|
|
32
33
|
SUCCESS = "success"
|
|
33
34
|
FAILURE = "failure"
|
|
34
35
|
SKIPPED = "skipped"
|
|
36
|
+
START = "start"
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
@dataclass(frozen=True)
|
|
@@ -68,3 +70,7 @@ class QueueEvent:
|
|
|
68
70
|
the actual exception object when one was raised; pass to OpenTelemetry
|
|
69
71
|
`span.record_exception(...)` for full trace attribution
|
|
70
72
|
"""
|
|
73
|
+
timeout_seconds: float | None = None
|
|
74
|
+
"""the caller-requested timeout for drain operations, when applicable"""
|
|
75
|
+
pending_claim_ids: int | None = None
|
|
76
|
+
"""number of unresolved pending claim ids for drain operations, when applicable"""
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import redis.exceptions
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RedisMessageQueueError(Exception):
|
|
5
|
+
"""Base class for redis-message-queue specific errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
*args: object,
|
|
10
|
+
queue: str | None = None,
|
|
11
|
+
message_id: str | None = None,
|
|
12
|
+
operation: str | None = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(*args)
|
|
15
|
+
self.queue = queue
|
|
16
|
+
self.message_id = message_id
|
|
17
|
+
self.operation = operation
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigurationError(RedisMessageQueueError, ValueError):
|
|
21
|
+
"""Bad parameter values or combinations."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GatewayContractError(RedisMessageQueueError, TypeError):
|
|
25
|
+
"""Custom gateway returned wrong type or violated contract."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LuaScriptError(redis.exceptions.ResponseError, RedisMessageQueueError):
|
|
29
|
+
"""A Lua script returned an unexpected error_reply."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*args: object,
|
|
34
|
+
queue: str | None = None,
|
|
35
|
+
message_id: str | None = None,
|
|
36
|
+
operation: str | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
RedisMessageQueueError.__init__(
|
|
39
|
+
self,
|
|
40
|
+
*args,
|
|
41
|
+
queue=queue,
|
|
42
|
+
message_id=message_id,
|
|
43
|
+
operation=operation,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CleanupFailedError(RedisMessageQueueError):
|
|
48
|
+
"""Cleanup after handler completion failed."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DrainFailedError(RedisMessageQueueError):
|
|
52
|
+
"""Wraps a non-RMQ exception caught during drain pending-claim recovery.
|
|
53
|
+
|
|
54
|
+
drain() returns False as the bool result; this exception carries
|
|
55
|
+
F7 context (queue, operation="drain") into the drain/failure event
|
|
56
|
+
payload so users diagnosing drain incidents via on_event see the
|
|
57
|
+
same structured attrs as elsewhere.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MalformedStoredMessageError(RedisMessageQueueError):
|
|
62
|
+
"""Stored value is not a valid RMQ envelope for the configured decode mode."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class QueueBackpressureError(RedisMessageQueueError):
|
|
66
|
+
"""Publish rejected because the pending queue is at its configured limit."""
|
|
67
|
+
|
|
68
|
+
_REMEDIATION = (
|
|
69
|
+
"consider increasing `max_pending_length`, switching to "
|
|
70
|
+
"`pending_overload_policy='block'`, or adding consumer capacity."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*args: object,
|
|
76
|
+
queue: str | None = None,
|
|
77
|
+
message_id: str | None = None,
|
|
78
|
+
operation: str | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
message = "Pending queue reached its configured limit" if not args else args[0]
|
|
81
|
+
if not isinstance(message, str) or len(args) > 1:
|
|
82
|
+
super().__init__(*args, queue=queue, message_id=message_id, operation=operation)
|
|
83
|
+
return
|
|
84
|
+
if self._REMEDIATION not in message:
|
|
85
|
+
message = f"{message}; {self._REMEDIATION}"
|
|
86
|
+
super().__init__(message, queue=queue, message_id=message_id, operation=operation)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class QueueDrainedError(RedisMessageQueueError):
|
|
90
|
+
"""Raised when publish() is called after drain() or aclose()."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RetryBudgetExhaustedError(redis.exceptions.RedisError, RedisMessageQueueError):
|
|
94
|
+
"""Tenacity retry budget exhausted; underlying redis-py exception is .__cause__."""
|
|
95
|
+
|
|
96
|
+
_REMEDIATION = (
|
|
97
|
+
"verify Redis connectivity and consider increasing `retry_budget_seconds` if transient failures are expected."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*args: object,
|
|
103
|
+
queue: str | None = None,
|
|
104
|
+
message_id: str | None = None,
|
|
105
|
+
operation: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
message = "Redis retry budget exhausted" if not args else args[0]
|
|
108
|
+
if not isinstance(message, str) or len(args) > 1:
|
|
109
|
+
RedisMessageQueueError.__init__(
|
|
110
|
+
self,
|
|
111
|
+
*args,
|
|
112
|
+
queue=queue,
|
|
113
|
+
message_id=message_id,
|
|
114
|
+
operation=operation,
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
if self._REMEDIATION not in message:
|
|
118
|
+
message = f"{message}; {self._REMEDIATION}"
|
|
119
|
+
RedisMessageQueueError.__init__(
|
|
120
|
+
self,
|
|
121
|
+
message,
|
|
122
|
+
queue=queue,
|
|
123
|
+
message_id=message_id,
|
|
124
|
+
operation=operation,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _set_exception_context(
|
|
129
|
+
exc: BaseException,
|
|
130
|
+
*,
|
|
131
|
+
queue: str | None = None,
|
|
132
|
+
message_id: str | None = None,
|
|
133
|
+
operation: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
if not isinstance(exc, RedisMessageQueueError):
|
|
136
|
+
return
|
|
137
|
+
if queue is not None:
|
|
138
|
+
exc.queue = queue
|
|
139
|
+
if message_id is not None:
|
|
140
|
+
exc.message_id = message_id
|
|
141
|
+
if operation is not None:
|
|
142
|
+
exc.operation = operation
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def wrap_lua_response_error(exc: redis.exceptions.ResponseError) -> LuaScriptError | None:
|
|
146
|
+
message = str(exc)
|
|
147
|
+
if message.startswith("WRONGTYPE ") or message.startswith("OOM during publish;"):
|
|
148
|
+
return LuaScriptError(message)
|
|
149
|
+
return None
|
{redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_queue_key_manager.py
RENAMED
|
@@ -17,6 +17,14 @@ def validate_callable_deduplication_key(dedup_key: object, message: str | dict)
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class QueueKeyManager:
|
|
20
|
+
"""Build Redis keys for one rmq queue namespace.
|
|
21
|
+
|
|
22
|
+
``key_separator`` is part of every generated key and rmq has no fixed
|
|
23
|
+
library prefix. Do not choose a separator that overlaps another Redis task
|
|
24
|
+
library's namespace, such as ``":queue:"`` with RQ-style keys; user-chosen
|
|
25
|
+
separators interact with every Redis user on the same DB.
|
|
26
|
+
"""
|
|
27
|
+
|
|
20
28
|
# Logs message existence to prevent duplication.
|
|
21
29
|
# Messages are marked for the duration of their lifecycle.
|
|
22
30
|
_MESSAGE_DEDUPLICATION_LOG = "deduplication"
|