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.
Files changed (26) hide show
  1. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/PKG-INFO +131 -4
  2. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/README.md +130 -3
  3. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/pyproject.toml +1 -1
  4. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/__init__.py +4 -0
  5. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_config.py +3 -0
  6. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_event.py +6 -0
  7. redis_message_queue-8.1.0/redis_message_queue/_exceptions.py +149 -0
  8. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_queue_key_manager.py +8 -0
  9. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_redis_cluster.py +25 -0
  10. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_redis_gateway.py +81 -16
  11. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_stored_message.py +16 -7
  12. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/__init__.py +8 -1
  13. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/_redis_gateway.py +81 -16
  14. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/redis_message_queue.py +304 -16
  15. redis_message_queue-8.1.0/redis_message_queue/interrupt_handler/__init__.py +9 -0
  16. redis_message_queue-8.1.0/redis_message_queue/interrupt_handler/_event_driven.py +24 -0
  17. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/interrupt_handler/_implementation.py +6 -0
  18. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/redis_message_queue.py +301 -13
  19. redis_message_queue-8.0.2/redis_message_queue/_exceptions.py +0 -71
  20. redis_message_queue-8.0.2/redis_message_queue/interrupt_handler/__init__.py +0 -4
  21. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/LICENSE +0 -0
  22. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_abstract_redis_gateway.py +0 -0
  23. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/_callable_utils.py +0 -0
  24. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +0 -0
  25. {redis_message_queue-8.0.2 → redis_message_queue-8.1.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {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.2
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
- Package logs remain diagnostic; use `on_event` rather than log parsing for
614
- metrics.
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
- Package logs remain diagnostic; use `on_event` rather than log parsing for
588
- metrics.
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "8.0.2"
3
+ version = "8.1.0"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = ["Elijas <4084885+Elijas@users.noreply.github.com>"]
6
6
  readme = "README.md"
@@ -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
@@ -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"