redis-message-queue 8.1.0__tar.gz → 8.2.1__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.2.1/.gitignore +158 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/PKG-INFO +16 -20
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/README.md +7 -7
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/pyproject.toml +56 -17
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/__init__.py +8 -1
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_abstract_redis_gateway.py +14 -7
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_config.py +13 -2
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_exceptions.py +16 -0
- redis_message_queue-8.2.1/redis_message_queue/_payload_limits.py +72 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_queue_key_manager.py +2 -1
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_redis_gateway.py +29 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_stored_message.py +1 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/__init__.py +8 -1
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +14 -7
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/_redis_gateway.py +29 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/redis_message_queue.py +35 -15
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/redis_message_queue.py +34 -15
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/LICENSE +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_event.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/py.typed +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
|
|
3
|
+
# Claude Code
|
|
4
|
+
.claude/
|
|
5
|
+
|
|
6
|
+
# Byte-compiled / optimized / DLL files
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*$py.class
|
|
10
|
+
|
|
11
|
+
# C extensions
|
|
12
|
+
*.so
|
|
13
|
+
|
|
14
|
+
# Distribution / packaging
|
|
15
|
+
.Python
|
|
16
|
+
build/
|
|
17
|
+
develop-eggs/
|
|
18
|
+
dist/
|
|
19
|
+
downloads/
|
|
20
|
+
eggs/
|
|
21
|
+
.eggs/
|
|
22
|
+
lib/
|
|
23
|
+
lib64/
|
|
24
|
+
parts/
|
|
25
|
+
sdist/
|
|
26
|
+
var/
|
|
27
|
+
wheels/
|
|
28
|
+
share/python-wheels/
|
|
29
|
+
*.egg-info/
|
|
30
|
+
.installed.cfg
|
|
31
|
+
*.egg
|
|
32
|
+
MANIFEST
|
|
33
|
+
|
|
34
|
+
# PyInstaller
|
|
35
|
+
# Usually these files are written by a python script from a template
|
|
36
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
37
|
+
*.manifest
|
|
38
|
+
*.spec
|
|
39
|
+
|
|
40
|
+
# Installer logs
|
|
41
|
+
pip-log.txt
|
|
42
|
+
pip-delete-this-directory.txt
|
|
43
|
+
|
|
44
|
+
# Unit test / coverage reports
|
|
45
|
+
htmlcov/
|
|
46
|
+
.tox/
|
|
47
|
+
.nox/
|
|
48
|
+
.coverage
|
|
49
|
+
.coverage.*
|
|
50
|
+
.cache
|
|
51
|
+
nosetests.xml
|
|
52
|
+
coverage.xml
|
|
53
|
+
*.cover
|
|
54
|
+
*.py,cover
|
|
55
|
+
.hypothesis/
|
|
56
|
+
.pytest_cache/
|
|
57
|
+
cover/
|
|
58
|
+
|
|
59
|
+
# Translations
|
|
60
|
+
*.mo
|
|
61
|
+
*.pot
|
|
62
|
+
|
|
63
|
+
# Django stuff:
|
|
64
|
+
*.log
|
|
65
|
+
local_settings.py
|
|
66
|
+
db.sqlite3
|
|
67
|
+
db.sqlite3-journal
|
|
68
|
+
|
|
69
|
+
# Flask stuff:
|
|
70
|
+
instance/
|
|
71
|
+
.webassets-cache
|
|
72
|
+
|
|
73
|
+
# Scrapy stuff:
|
|
74
|
+
.scrapy
|
|
75
|
+
|
|
76
|
+
# Sphinx documentation
|
|
77
|
+
docs/_build/
|
|
78
|
+
|
|
79
|
+
# PyBuilder
|
|
80
|
+
.pybuilder/
|
|
81
|
+
target/
|
|
82
|
+
|
|
83
|
+
# Jupyter Notebook
|
|
84
|
+
.ipynb_checkpoints
|
|
85
|
+
|
|
86
|
+
# IPython
|
|
87
|
+
profile_default/
|
|
88
|
+
ipython_config.py
|
|
89
|
+
|
|
90
|
+
# pyenv
|
|
91
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
92
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
93
|
+
# .python-version
|
|
94
|
+
|
|
95
|
+
# pipenv
|
|
96
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
97
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
98
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
99
|
+
# install all needed dependencies.
|
|
100
|
+
#Pipfile.lock
|
|
101
|
+
|
|
102
|
+
# pdm
|
|
103
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
104
|
+
#pdm.lock
|
|
105
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
106
|
+
# in version control.
|
|
107
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
108
|
+
.pdm.toml
|
|
109
|
+
|
|
110
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
111
|
+
__pypackages__/
|
|
112
|
+
|
|
113
|
+
# Celery stuff
|
|
114
|
+
celerybeat-schedule
|
|
115
|
+
celerybeat.pid
|
|
116
|
+
|
|
117
|
+
# SageMath parsed files
|
|
118
|
+
*.sage.py
|
|
119
|
+
|
|
120
|
+
# Environments
|
|
121
|
+
.env
|
|
122
|
+
.venv
|
|
123
|
+
env/
|
|
124
|
+
venv/
|
|
125
|
+
ENV/
|
|
126
|
+
env.bak/
|
|
127
|
+
venv.bak/
|
|
128
|
+
|
|
129
|
+
# Spyder project settings
|
|
130
|
+
.spyderproject
|
|
131
|
+
.spyproject
|
|
132
|
+
|
|
133
|
+
# Rope project settings
|
|
134
|
+
.ropeproject
|
|
135
|
+
|
|
136
|
+
# mkdocs documentation
|
|
137
|
+
/site
|
|
138
|
+
|
|
139
|
+
# mypy
|
|
140
|
+
.mypy_cache/
|
|
141
|
+
.dmypy.json
|
|
142
|
+
dmypy.json
|
|
143
|
+
|
|
144
|
+
# Pyre type checker
|
|
145
|
+
.pyre/
|
|
146
|
+
|
|
147
|
+
# pytype static type analyzer
|
|
148
|
+
.pytype/
|
|
149
|
+
|
|
150
|
+
# Cython debug symbols
|
|
151
|
+
cython_debug/
|
|
152
|
+
|
|
153
|
+
# PyCharm
|
|
154
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
155
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
156
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
157
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
158
|
+
#.idea/
|
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redis-message-queue
|
|
3
|
-
Version: 8.1
|
|
3
|
+
Version: 8.2.1
|
|
4
4
|
Summary: Python message queuing with Redis and message deduplication
|
|
5
|
+
Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
|
|
6
|
+
Project-URL: Repository, https://github.com/Elijas/redis-message-queue
|
|
7
|
+
Project-URL: Issues, https://github.com/Elijas/redis-message-queue/issues
|
|
8
|
+
Author-email: Elijas <4084885+Elijas@users.noreply.github.com>
|
|
5
9
|
License: MIT
|
|
6
10
|
License-File: LICENSE
|
|
7
|
-
Keywords:
|
|
8
|
-
Author: Elijas
|
|
9
|
-
Author-email: 4084885+Elijas@users.noreply.github.com
|
|
10
|
-
Requires-Python: >=3.12,<4.0
|
|
11
|
+
Keywords: deduplication,message-queue,redis,task-queue
|
|
11
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
13
|
Classifier: Intended Audience :: Developers
|
|
13
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Programming Language :: Python :: 3.14
|
|
18
17
|
Classifier: Topic :: Software Development :: Libraries
|
|
19
18
|
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
-
Requires-
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
|
|
23
|
-
Project-URL: Issues, https://github.com/Elijas/redis-message-queue/issues
|
|
24
|
-
Project-URL: Repository, https://github.com/Elijas/redis-message-queue
|
|
19
|
+
Requires-Python: <4.0,>=3.12
|
|
20
|
+
Requires-Dist: redis<8.0.0,>=5.0.1
|
|
21
|
+
Requires-Dist: tenacity>=8.1.0
|
|
25
22
|
Description-Content-Type: text/markdown
|
|
26
23
|
|
|
27
24
|
# redis-message-queue
|
|
28
25
|
|
|
29
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
30
27
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
31
28
|
[](LICENSE)
|
|
32
29
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -37,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
37
34
|
**Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
|
|
38
35
|
|
|
39
36
|
```bash
|
|
40
|
-
pip install "redis-message-queue>=8.
|
|
37
|
+
pip install "redis-message-queue>=8.2.1,<9.0.0"
|
|
41
38
|
```
|
|
42
39
|
|
|
43
40
|
Requires Redis server >= 6.2.
|
|
@@ -1090,14 +1087,13 @@ Try the [examples](https://github.com/Elijas/redis-message-queue/tree/main/examp
|
|
|
1090
1087
|
|
|
1091
1088
|
```bash
|
|
1092
1089
|
# Two publishers
|
|
1093
|
-
|
|
1094
|
-
|
|
1090
|
+
uv run python -m examples.send_messages
|
|
1091
|
+
uv run python -m examples.send_messages
|
|
1095
1092
|
|
|
1096
1093
|
# Three consumers
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1094
|
+
uv run python -m examples.receive_messages
|
|
1095
|
+
uv run python -m examples.receive_messages
|
|
1096
|
+
uv run python -m examples.receive_messages
|
|
1100
1097
|
```
|
|
1101
1098
|
|
|
1102
1099
|

|
|
1103
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# redis-message-queue
|
|
2
2
|
|
|
3
|
-
[](https://pypi.org/project/redis-message-queue)
|
|
4
4
|
[](https://pypistats.org/packages/redis-message-queue)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](https://github.com/Elijas/redis-message-queue/issues)
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
**Lightweight Python message queuing with Redis and built-in publish-side deduplication.** Deduplicate publishes within a TTL window, with optional crash recovery — across any number of producers and consumers.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
pip install "redis-message-queue>=8.
|
|
14
|
+
pip install "redis-message-queue>=8.2.1,<9.0.0"
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Requires Redis server >= 6.2.
|
|
@@ -1064,13 +1064,13 @@ Try the [examples](https://github.com/Elijas/redis-message-queue/tree/main/examp
|
|
|
1064
1064
|
|
|
1065
1065
|
```bash
|
|
1066
1066
|
# Two publishers
|
|
1067
|
-
|
|
1068
|
-
|
|
1067
|
+
uv run python -m examples.send_messages
|
|
1068
|
+
uv run python -m examples.send_messages
|
|
1069
1069
|
|
|
1070
1070
|
# Three consumers
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1071
|
+
uv run python -m examples.receive_messages
|
|
1072
|
+
uv run python -m examples.receive_messages
|
|
1073
|
+
uv run python -m examples.receive_messages
|
|
1074
1074
|
```
|
|
1075
1075
|
|
|
1076
1076
|

|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
[
|
|
1
|
+
[project]
|
|
2
2
|
name = "redis-message-queue"
|
|
3
|
-
version = "8.1
|
|
3
|
+
version = "8.2.1"
|
|
4
4
|
description = "Python message queuing with Redis and message deduplication"
|
|
5
|
-
authors = ["Elijas
|
|
5
|
+
authors = [{ name = "Elijas", email = "4084885+Elijas@users.noreply.github.com" }]
|
|
6
6
|
readme = "README.md"
|
|
7
|
-
license = "MIT"
|
|
8
|
-
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
9
|
keywords = ["redis", "message-queue", "deduplication", "task-queue"]
|
|
10
10
|
classifiers = [
|
|
11
11
|
"Development Status :: 5 - Production/Stable",
|
|
@@ -16,26 +16,65 @@ classifiers = [
|
|
|
16
16
|
"Topic :: Software Development :: Libraries",
|
|
17
17
|
"Topic :: System :: Distributed Computing",
|
|
18
18
|
]
|
|
19
|
+
requires-python = ">=3.12,<4.0"
|
|
20
|
+
dependencies = [
|
|
21
|
+
"redis>=5.0.1,<8.0.0",
|
|
22
|
+
"tenacity>=8.1.0",
|
|
23
|
+
]
|
|
19
24
|
|
|
20
|
-
[
|
|
25
|
+
[project.urls]
|
|
21
26
|
Homepage = "https://github.com/Elijas/redis-message-queue"
|
|
22
27
|
Repository = "https://github.com/Elijas/redis-message-queue"
|
|
23
28
|
Issues = "https://github.com/Elijas/redis-message-queue/issues"
|
|
24
29
|
|
|
25
|
-
[
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"bump-my-version>=1.1.2",
|
|
33
|
+
"mypy",
|
|
34
|
+
"ruff",
|
|
35
|
+
]
|
|
36
|
+
test = [
|
|
37
|
+
"fakeredis[lua]",
|
|
38
|
+
"pytest",
|
|
39
|
+
"pytest-asyncio",
|
|
40
|
+
"pytest-cov",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.uv]
|
|
44
|
+
default-groups = ["dev", "test"]
|
|
45
|
+
|
|
46
|
+
##############################
|
|
47
|
+
### BUMP VERSION
|
|
48
|
+
##############################
|
|
29
49
|
|
|
30
|
-
[tool.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
[tool.bumpversion]
|
|
51
|
+
current_version = "8.2.1"
|
|
52
|
+
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
53
|
+
serialize = ["{major}.{minor}.{patch}"]
|
|
54
|
+
search = "{current_version}"
|
|
55
|
+
replace = "{new_version}"
|
|
56
|
+
regex = false
|
|
57
|
+
ignore_missing_version = false
|
|
58
|
+
ignore_missing_files = false
|
|
59
|
+
tag = false
|
|
60
|
+
sign_tags = false
|
|
61
|
+
tag_name = "v{new_version}"
|
|
62
|
+
tag_message = "Bump version: {current_version} -> {new_version}"
|
|
63
|
+
allow_dirty = true
|
|
64
|
+
commit = true
|
|
65
|
+
message = "chore(release): bump version from {current_version} to {new_version}"
|
|
66
|
+
moveable_tags = []
|
|
67
|
+
commit_args = ""
|
|
68
|
+
setup_hooks = []
|
|
69
|
+
pre_commit_hooks = []
|
|
70
|
+
post_commit_hooks = []
|
|
35
71
|
|
|
36
72
|
[build-system]
|
|
37
|
-
requires = ["
|
|
38
|
-
build-backend = "
|
|
73
|
+
requires = ["hatchling"]
|
|
74
|
+
build-backend = "hatchling.build"
|
|
75
|
+
|
|
76
|
+
[tool.hatch.build]
|
|
77
|
+
packages = ["redis_message_queue"]
|
|
39
78
|
|
|
40
79
|
[tool.ruff]
|
|
41
80
|
target-version = "py312"
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
from redis_message_queue._abstract_redis_gateway import AbstractRedisGateway
|
|
2
2
|
from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
|
|
3
3
|
from redis_message_queue._exceptions import (
|
|
4
|
+
ClaimStoreFailedError,
|
|
4
5
|
CleanupFailedError,
|
|
5
6
|
ConfigurationError,
|
|
6
7
|
DrainFailedError,
|
|
7
8
|
GatewayContractError,
|
|
8
9
|
LuaScriptError,
|
|
9
10
|
MalformedStoredMessageError,
|
|
11
|
+
PayloadTooDeepError,
|
|
12
|
+
PayloadTooLargeError,
|
|
10
13
|
QueueBackpressureError,
|
|
11
14
|
QueueDrainedError,
|
|
12
15
|
RedisMessageQueueError,
|
|
13
16
|
RetryBudgetExhaustedError,
|
|
14
17
|
)
|
|
15
18
|
from redis_message_queue._redis_gateway import RedisGateway
|
|
16
|
-
from redis_message_queue._stored_message import ClaimedMessage, MessageData
|
|
19
|
+
from redis_message_queue._stored_message import ClaimedMessage, MessageData, MessagePayload
|
|
17
20
|
from redis_message_queue.interrupt_handler import (
|
|
18
21
|
BaseGracefulInterruptHandler,
|
|
19
22
|
EventDrivenInterruptHandler,
|
|
@@ -27,6 +30,7 @@ __all__ = [
|
|
|
27
30
|
"AbstractRedisGateway",
|
|
28
31
|
"ClaimedMessage",
|
|
29
32
|
"MessageData",
|
|
33
|
+
"MessagePayload",
|
|
30
34
|
"EventDrivenInterruptHandler",
|
|
31
35
|
"GracefulInterruptHandler",
|
|
32
36
|
"BaseGracefulInterruptHandler",
|
|
@@ -34,11 +38,14 @@ __all__ = [
|
|
|
34
38
|
"EventOperation",
|
|
35
39
|
"EventOutcome",
|
|
36
40
|
"RedisMessageQueueError",
|
|
41
|
+
"ClaimStoreFailedError",
|
|
37
42
|
"ConfigurationError",
|
|
38
43
|
"DrainFailedError",
|
|
39
44
|
"GatewayContractError",
|
|
40
45
|
"LuaScriptError",
|
|
41
46
|
"MalformedStoredMessageError",
|
|
47
|
+
"PayloadTooLargeError",
|
|
48
|
+
"PayloadTooDeepError",
|
|
42
49
|
"QueueBackpressureError",
|
|
43
50
|
"QueueDrainedError",
|
|
44
51
|
"CleanupFailedError",
|
|
@@ -12,13 +12,12 @@ class AbstractRedisGateway(ABC):
|
|
|
12
12
|
gateways MUST uphold the same behavioral contracts documented on each method
|
|
13
13
|
to avoid phantom heartbeats, undetected lease conflicts, or silent data loss.
|
|
14
14
|
|
|
15
|
-
Gateways that support visibility timeouts (lease-based claiming) MUST
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
causing the queue to treat the gateway as a non-lease implementation.
|
|
15
|
+
Gateways that support visibility timeouts (lease-based claiming) MUST
|
|
16
|
+
override the ``message_visibility_timeout_seconds`` property with a positive
|
|
17
|
+
int. The abstract base declares this property with a ``None`` default so
|
|
18
|
+
non-lease custom gateways keep the existing behavior, while lease-capable
|
|
19
|
+
custom gateways have a typeable contract to override. A positive value is
|
|
20
|
+
required when the queue is configured with ``heartbeat_interval_seconds``.
|
|
22
21
|
|
|
23
22
|
The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
|
|
24
23
|
from the gateway. The abstract base provides ``None`` defaults via
|
|
@@ -65,6 +64,14 @@ class AbstractRedisGateway(ABC):
|
|
|
65
64
|
def dead_letter_queue(self) -> str | None:
|
|
66
65
|
return None
|
|
67
66
|
|
|
67
|
+
@property
|
|
68
|
+
def message_visibility_timeout_seconds(self) -> int | None:
|
|
69
|
+
"""Visibility timeout (lease duration) in seconds. Override to enable
|
|
70
|
+
lease-based crash recovery; return None to disable. Required when the
|
|
71
|
+
queue is configured with ``heartbeat_interval_seconds``.
|
|
72
|
+
"""
|
|
73
|
+
return None
|
|
74
|
+
|
|
68
75
|
@abstractmethod
|
|
69
76
|
def publish_message(self, queue: str, message: str, dedup_key: str) -> bool:
|
|
70
77
|
"""Publish a message with deduplication.
|
|
@@ -31,6 +31,7 @@ DEFAULT_RETRY_INITIAL_DELAY_SECONDS = 0.01
|
|
|
31
31
|
DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS = 1.0
|
|
32
32
|
INTERRUPTIBLE_RETRY_SLEEP_POLL_SECONDS = 0.05
|
|
33
33
|
PENDING_OVERLOAD_LUA_SENTINEL = -1
|
|
34
|
+
CLAIM_STORE_FAILED_LUA_SENTINEL = "\0__rmq_claim_store_failed__"
|
|
34
35
|
PENDING_OVERLOAD_POLICIES = ("raise", "drop_oldest", "block")
|
|
35
36
|
DEDUPLICATION_REQUIRES_KEY_MESSAGE = (
|
|
36
37
|
"deduplication=True requires get_deduplication_key (callable returning a non-empty str). "
|
|
@@ -53,6 +54,13 @@ def is_redis_retryable_exception(exception):
|
|
|
53
54
|
if isinstance(exception, redis.exceptions.ClusterError) and "TTL exhausted" in str(exception):
|
|
54
55
|
return True
|
|
55
56
|
|
|
57
|
+
no_script_error = getattr(redis.exceptions, "NoScriptError", None)
|
|
58
|
+
if no_script_error is not None and isinstance(exception, no_script_error):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
if isinstance(exception, redis.exceptions.ResponseError) and str(exception).startswith("NOSCRIPT"):
|
|
62
|
+
return True
|
|
63
|
+
|
|
56
64
|
# 2. Explicit retryable exceptions (BusyLoadingError is a ConnectionError
|
|
57
65
|
# subclass, so it is already handled by branch 1 above)
|
|
58
66
|
return isinstance(
|
|
@@ -832,11 +840,13 @@ if #to_requeue > 0 then
|
|
|
832
840
|
redis.call('RPUSH', KEYS[1], unpack(to_requeue))
|
|
833
841
|
end
|
|
834
842
|
local dead_lettered_events = {}
|
|
843
|
+
local claim_store_failed_sentinel = string.char(0) .. '__rmq_claim_store_failed__'
|
|
835
844
|
|
|
836
845
|
local function store_claim_and_return(stored)
|
|
837
846
|
-- pcall guards against OOM mid-write: compensate by returning message to pending
|
|
838
847
|
local ok, result = pcall(function()
|
|
839
|
-
|
|
848
|
+
redis.call('INCR', KEYS[5])
|
|
849
|
+
local lease_token = redis.call('GET', KEYS[5])
|
|
840
850
|
local claim_payload = cjson.encode({stored, lease_token})
|
|
841
851
|
redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
|
|
842
852
|
redis.call('HSET', KEYS[4], stored, lease_token)
|
|
@@ -847,9 +857,10 @@ local function store_claim_and_return(stored)
|
|
|
847
857
|
return {stored, lease_token, reclaimed_events, dead_lettered_events}
|
|
848
858
|
end)
|
|
849
859
|
if not ok then
|
|
860
|
+
redis.call('HINCRBY', KEYS[6], stored, -1)
|
|
850
861
|
redis.call('LREM', KEYS[2], 1, stored)
|
|
851
862
|
redis.pcall('RPUSH', KEYS[1], stored)
|
|
852
|
-
return
|
|
863
|
+
return {claim_store_failed_sentinel, tostring(result), stored}
|
|
853
864
|
end
|
|
854
865
|
return result
|
|
855
866
|
end
|
|
@@ -48,6 +48,14 @@ class CleanupFailedError(RedisMessageQueueError):
|
|
|
48
48
|
"""Cleanup after handler completion failed."""
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
class ClaimStoreFailedError(RedisMessageQueueError):
|
|
52
|
+
"""Raised when the VT-claim Lua store_claim_and_return pcall failed.
|
|
53
|
+
|
|
54
|
+
The script decremented the speculative delivery_count increment and
|
|
55
|
+
compensated by returning the message to pending before surfacing this error.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
51
59
|
class DrainFailedError(RedisMessageQueueError):
|
|
52
60
|
"""Wraps a non-RMQ exception caught during drain pending-claim recovery.
|
|
53
61
|
|
|
@@ -62,6 +70,14 @@ class MalformedStoredMessageError(RedisMessageQueueError):
|
|
|
62
70
|
"""Stored value is not a valid RMQ envelope for the configured decode mode."""
|
|
63
71
|
|
|
64
72
|
|
|
73
|
+
class PayloadTooLargeError(RedisMessageQueueError, ValueError):
|
|
74
|
+
"""Publish payload exceeds the configured serialized byte limit."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PayloadTooDeepError(RedisMessageQueueError, ValueError):
|
|
78
|
+
"""Publish payload exceeds the configured nesting-depth limit."""
|
|
79
|
+
|
|
80
|
+
|
|
65
81
|
class QueueBackpressureError(RedisMessageQueueError):
|
|
66
82
|
"""Publish rejected because the pending queue is at its configured limit."""
|
|
67
83
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from redis_message_queue._exceptions import ConfigurationError, PayloadTooDeepError, PayloadTooLargeError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_payload_limit_parameter(name: str, value: int | None) -> int | None:
|
|
7
|
+
if value is None:
|
|
8
|
+
return None
|
|
9
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
10
|
+
bool_hint = " (use a positive int or None, not True/False)" if isinstance(value, bool) else ""
|
|
11
|
+
raise TypeError(f"'{name}' must be an int or None, got {type(value).__name__}{bool_hint}")
|
|
12
|
+
if value <= 0:
|
|
13
|
+
raise ConfigurationError(f"'{name}' must be positive when provided, got {value}")
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_max_payload_depth(message: dict, max_payload_depth: int | None) -> None:
|
|
18
|
+
if max_payload_depth is None:
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
stack: list[tuple[object, str, int]] = [(message, "message", 0)]
|
|
22
|
+
seen: set[int] = set()
|
|
23
|
+
while stack:
|
|
24
|
+
value, path, depth = stack.pop()
|
|
25
|
+
if depth > max_payload_depth:
|
|
26
|
+
raise PayloadTooDeepError(
|
|
27
|
+
f"max_payload_depth={max_payload_depth} exceeded: depth {depth} reached at {path}"
|
|
28
|
+
)
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
current_id = id(value)
|
|
31
|
+
if current_id in seen:
|
|
32
|
+
continue
|
|
33
|
+
seen.add(current_id)
|
|
34
|
+
children = list(value.items())
|
|
35
|
+
for key, child in reversed(children):
|
|
36
|
+
stack.append((child, f"{path}[{key!r}]", depth + 1))
|
|
37
|
+
elif isinstance(value, (list, tuple)):
|
|
38
|
+
current_id = id(value)
|
|
39
|
+
if current_id in seen:
|
|
40
|
+
continue
|
|
41
|
+
seen.add(current_id)
|
|
42
|
+
for index in range(len(value) - 1, -1, -1):
|
|
43
|
+
stack.append((value[index], f"{path}[{index}]", depth + 1))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def serialize_dict_payload_with_limit(message: dict, max_payload_bytes: int | None) -> str:
|
|
47
|
+
message_str = json.dumps(message, sort_keys=True, allow_nan=False)
|
|
48
|
+
if max_payload_bytes is not None:
|
|
49
|
+
validate_max_payload_bytes(
|
|
50
|
+
len(message_str.encode("utf-8")),
|
|
51
|
+
max_payload_bytes,
|
|
52
|
+
payload_type="dict message",
|
|
53
|
+
)
|
|
54
|
+
return message_str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_str_payload_size(message: str, max_payload_bytes: int | None) -> None:
|
|
58
|
+
if max_payload_bytes is None:
|
|
59
|
+
return
|
|
60
|
+
validate_max_payload_bytes(
|
|
61
|
+
len(message.encode("utf-8")),
|
|
62
|
+
max_payload_bytes,
|
|
63
|
+
payload_type="str message",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_max_payload_bytes(size_bytes: int, max_payload_bytes: int | None, *, payload_type: str) -> None:
|
|
68
|
+
if max_payload_bytes is None or size_bytes <= max_payload_bytes:
|
|
69
|
+
return
|
|
70
|
+
raise PayloadTooLargeError(
|
|
71
|
+
f"max_payload_bytes={max_payload_bytes} exceeded: payload is {size_bytes} bytes ({payload_type})"
|
|
72
|
+
)
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_queue_key_manager.py
RENAMED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from redis_message_queue._exceptions import ConfigurationError
|
|
2
|
+
from redis_message_queue._stored_message import MessagePayload
|
|
2
3
|
|
|
3
4
|
|
|
4
|
-
def validate_callable_deduplication_key(dedup_key: object, message:
|
|
5
|
+
def validate_callable_deduplication_key(dedup_key: object, message: MessagePayload) -> str:
|
|
5
6
|
if dedup_key is None:
|
|
6
7
|
raise ConfigurationError(
|
|
7
8
|
f"get_deduplication_key returned None for message {message!r}; the callable must return a non-empty string"
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_redis_gateway.py
RENAMED
|
@@ -16,6 +16,7 @@ from redis_message_queue._config import (
|
|
|
16
16
|
ADD_MESSAGE_LUA_SCRIPT,
|
|
17
17
|
CLAIM_MESSAGE_LUA_SCRIPT,
|
|
18
18
|
CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
|
|
19
|
+
CLAIM_STORE_FAILED_LUA_SENTINEL,
|
|
19
20
|
CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
|
|
20
21
|
DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
|
|
21
22
|
DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
|
|
@@ -39,6 +40,7 @@ from redis_message_queue._config import (
|
|
|
39
40
|
)
|
|
40
41
|
from redis_message_queue._event import EventOperation, EventOutcome
|
|
41
42
|
from redis_message_queue._exceptions import (
|
|
43
|
+
ClaimStoreFailedError,
|
|
42
44
|
ConfigurationError,
|
|
43
45
|
QueueBackpressureError,
|
|
44
46
|
RedisMessageQueueError,
|
|
@@ -76,6 +78,7 @@ _OPTIONAL_DEAD_LETTER_PLACEHOLDER_SUFFIX = ":dead_letter_placeholder"
|
|
|
76
78
|
_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
|
|
77
79
|
_PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
|
|
78
80
|
_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
|
|
81
|
+
_CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES = CLAIM_STORE_FAILED_LUA_SENTINEL.encode("utf-8")
|
|
79
82
|
|
|
80
83
|
|
|
81
84
|
class _DrainDeadlineExceeded(Exception):
|
|
@@ -138,6 +141,22 @@ def _decode_lua_text(value: object) -> str | None:
|
|
|
138
141
|
return None
|
|
139
142
|
|
|
140
143
|
|
|
144
|
+
def _decode_lua_error(value: object) -> str:
|
|
145
|
+
if isinstance(value, bytes):
|
|
146
|
+
return value.decode("utf-8", errors="replace")
|
|
147
|
+
if isinstance(value, str) and value:
|
|
148
|
+
return value
|
|
149
|
+
return "unknown Lua error"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_claim_store_failed_result(result: object) -> bool:
|
|
153
|
+
return (
|
|
154
|
+
isinstance(result, list | tuple)
|
|
155
|
+
and len(result) >= 2
|
|
156
|
+
and result[0] in (CLAIM_STORE_FAILED_LUA_SENTINEL, _CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
141
160
|
def _coerce_lua_message_attempts(value: object) -> list[_MessageAttemptEvent]:
|
|
142
161
|
if not isinstance(value, list | tuple):
|
|
143
162
|
return []
|
|
@@ -931,6 +950,16 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
931
950
|
if result is None:
|
|
932
951
|
return None
|
|
933
952
|
|
|
953
|
+
if _is_claim_store_failed_result(result):
|
|
954
|
+
stored_message = result[2] if len(result) > 2 else None
|
|
955
|
+
message_id = extract_stored_message_id(stored_message) if isinstance(stored_message, (str, bytes)) else None
|
|
956
|
+
raise ClaimStoreFailedError(
|
|
957
|
+
f"VT claim store failed after delivery_count rollback: {_decode_lua_error(result[1])}",
|
|
958
|
+
queue=from_queue,
|
|
959
|
+
message_id=message_id,
|
|
960
|
+
operation="claim",
|
|
961
|
+
)
|
|
962
|
+
|
|
934
963
|
stored_message, lease_token = result[0], result[1]
|
|
935
964
|
reclaimed_attempts = _coerce_lua_message_attempts(result[2]) if len(result) > 2 else []
|
|
936
965
|
dead_lettered_attempts = _coerce_lua_message_attempts(result[3]) if len(result) > 3 else []
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_stored_message.py
RENAMED
|
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from redis_message_queue._exceptions import MalformedStoredMessageError
|
|
6
6
|
|
|
7
7
|
MessageData = str | bytes
|
|
8
|
+
MessagePayload = str | dict[str, object]
|
|
8
9
|
|
|
9
10
|
_STORED_MESSAGE_PREFIX = "\x1eRMQ1:"
|
|
10
11
|
_STORED_MESSAGE_PREFIX_BYTES = _STORED_MESSAGE_PREFIX.encode("utf-8")
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/__init__.py
RENAMED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
|
|
2
2
|
from redis_message_queue._exceptions import (
|
|
3
|
+
ClaimStoreFailedError,
|
|
3
4
|
CleanupFailedError,
|
|
4
5
|
ConfigurationError,
|
|
5
6
|
DrainFailedError,
|
|
6
7
|
GatewayContractError,
|
|
7
8
|
LuaScriptError,
|
|
8
9
|
MalformedStoredMessageError,
|
|
10
|
+
PayloadTooDeepError,
|
|
11
|
+
PayloadTooLargeError,
|
|
9
12
|
QueueBackpressureError,
|
|
10
13
|
QueueDrainedError,
|
|
11
14
|
RedisMessageQueueError,
|
|
12
15
|
RetryBudgetExhaustedError,
|
|
13
16
|
)
|
|
14
|
-
from redis_message_queue._stored_message import ClaimedMessage, MessageData
|
|
17
|
+
from redis_message_queue._stored_message import ClaimedMessage, MessageData, MessagePayload
|
|
15
18
|
from redis_message_queue.asyncio._abstract_redis_gateway import AbstractRedisGateway
|
|
16
19
|
from redis_message_queue.asyncio._redis_gateway import RedisGateway
|
|
17
20
|
from redis_message_queue.asyncio.redis_message_queue import RedisMessageQueue
|
|
@@ -27,6 +30,7 @@ __all__ = [
|
|
|
27
30
|
"AbstractRedisGateway",
|
|
28
31
|
"ClaimedMessage",
|
|
29
32
|
"MessageData",
|
|
33
|
+
"MessagePayload",
|
|
30
34
|
"EventDrivenInterruptHandler",
|
|
31
35
|
"GracefulInterruptHandler",
|
|
32
36
|
"BaseGracefulInterruptHandler",
|
|
@@ -34,11 +38,14 @@ __all__ = [
|
|
|
34
38
|
"EventOperation",
|
|
35
39
|
"EventOutcome",
|
|
36
40
|
"RedisMessageQueueError",
|
|
41
|
+
"ClaimStoreFailedError",
|
|
37
42
|
"ConfigurationError",
|
|
38
43
|
"DrainFailedError",
|
|
39
44
|
"GatewayContractError",
|
|
40
45
|
"LuaScriptError",
|
|
41
46
|
"MalformedStoredMessageError",
|
|
47
|
+
"PayloadTooLargeError",
|
|
48
|
+
"PayloadTooDeepError",
|
|
42
49
|
"QueueBackpressureError",
|
|
43
50
|
"QueueDrainedError",
|
|
44
51
|
"CleanupFailedError",
|
|
@@ -13,13 +13,12 @@ class AbstractRedisGateway(ABC):
|
|
|
13
13
|
documented on each method to avoid phantom heartbeats, undetected lease conflicts,
|
|
14
14
|
or silent data loss.
|
|
15
15
|
|
|
16
|
-
Gateways that support visibility timeouts (lease-based claiming) MUST
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
causing the queue to treat the gateway as a non-lease implementation.
|
|
16
|
+
Gateways that support visibility timeouts (lease-based claiming) MUST
|
|
17
|
+
override the ``message_visibility_timeout_seconds`` property with a positive
|
|
18
|
+
int. The abstract base declares this property with a ``None`` default so
|
|
19
|
+
non-lease custom gateways keep the existing behavior, while lease-capable
|
|
20
|
+
custom gateways have a typeable contract to override. A positive value is
|
|
21
|
+
required when the queue is configured with ``heartbeat_interval_seconds``.
|
|
23
22
|
|
|
24
23
|
The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
|
|
25
24
|
from the gateway. The abstract base provides ``None`` defaults via
|
|
@@ -65,6 +64,14 @@ class AbstractRedisGateway(ABC):
|
|
|
65
64
|
def dead_letter_queue(self) -> str | None:
|
|
66
65
|
return None
|
|
67
66
|
|
|
67
|
+
@property
|
|
68
|
+
def message_visibility_timeout_seconds(self) -> int | None:
|
|
69
|
+
"""Visibility timeout (lease duration) in seconds. Override to enable
|
|
70
|
+
lease-based crash recovery; return None to disable. Required when the
|
|
71
|
+
queue is configured with ``heartbeat_interval_seconds``.
|
|
72
|
+
"""
|
|
73
|
+
return None
|
|
74
|
+
|
|
68
75
|
@abstractmethod
|
|
69
76
|
async def publish_message(self, queue: str, message: str, dedup_key: str) -> bool:
|
|
70
77
|
"""Publish a message with deduplication.
|
|
@@ -14,6 +14,7 @@ from redis_message_queue._config import (
|
|
|
14
14
|
ADD_MESSAGE_LUA_SCRIPT,
|
|
15
15
|
CLAIM_MESSAGE_LUA_SCRIPT,
|
|
16
16
|
CLAIM_MESSAGE_WITH_VISIBILITY_TIMEOUT_LUA_SCRIPT,
|
|
17
|
+
CLAIM_STORE_FAILED_LUA_SENTINEL,
|
|
17
18
|
CLEANUP_DRAINED_LEASE_TOKEN_COUNTER_LUA_SCRIPT,
|
|
18
19
|
DEFAULT_MESSAGE_DEDUPLICATION_LOG_TTL,
|
|
19
20
|
DEFAULT_MESSAGE_WAIT_INTERVAL_SECONDS,
|
|
@@ -37,6 +38,7 @@ from redis_message_queue._config import (
|
|
|
37
38
|
)
|
|
38
39
|
from redis_message_queue._event import EventOperation, EventOutcome
|
|
39
40
|
from redis_message_queue._exceptions import (
|
|
41
|
+
ClaimStoreFailedError,
|
|
40
42
|
ConfigurationError,
|
|
41
43
|
QueueBackpressureError,
|
|
42
44
|
RedisMessageQueueError,
|
|
@@ -75,6 +77,7 @@ _OPTIONAL_DEAD_LETTER_PLACEHOLDER_SUFFIX = ":dead_letter_placeholder"
|
|
|
75
77
|
_VISIBILITY_TIMEOUT_POLL_INTERVAL_SECONDS = 0.25
|
|
76
78
|
_PENDING_OVERLOAD_INITIAL_BACKOFF_SECONDS = 0.010
|
|
77
79
|
_PENDING_OVERLOAD_MAX_BACKOFF_SECONDS = 0.500
|
|
80
|
+
_CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES = CLAIM_STORE_FAILED_LUA_SENTINEL.encode("utf-8")
|
|
78
81
|
|
|
79
82
|
|
|
80
83
|
class _DrainDeadlineExceeded(Exception):
|
|
@@ -121,6 +124,22 @@ def _decode_lua_text(value: object) -> str | None:
|
|
|
121
124
|
return None
|
|
122
125
|
|
|
123
126
|
|
|
127
|
+
def _decode_lua_error(value: object) -> str:
|
|
128
|
+
if isinstance(value, bytes):
|
|
129
|
+
return value.decode("utf-8", errors="replace")
|
|
130
|
+
if isinstance(value, str) and value:
|
|
131
|
+
return value
|
|
132
|
+
return "unknown Lua error"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_claim_store_failed_result(result: object) -> bool:
|
|
136
|
+
return (
|
|
137
|
+
isinstance(result, list | tuple)
|
|
138
|
+
and len(result) >= 2
|
|
139
|
+
and result[0] in (CLAIM_STORE_FAILED_LUA_SENTINEL, _CLAIM_STORE_FAILED_LUA_SENTINEL_BYTES)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
124
143
|
def _coerce_lua_message_attempts(value: object) -> list[_MessageAttemptEvent]:
|
|
125
144
|
if not isinstance(value, list | tuple):
|
|
126
145
|
return []
|
|
@@ -911,6 +930,16 @@ class RedisGateway(AbstractRedisGateway):
|
|
|
911
930
|
if result is None:
|
|
912
931
|
return None
|
|
913
932
|
|
|
933
|
+
if _is_claim_store_failed_result(result):
|
|
934
|
+
stored_message = result[2] if len(result) > 2 else None
|
|
935
|
+
message_id = extract_stored_message_id(stored_message) if isinstance(stored_message, (str, bytes)) else None
|
|
936
|
+
raise ClaimStoreFailedError(
|
|
937
|
+
f"VT claim store failed after delivery_count rollback: {_decode_lua_error(result[1])}",
|
|
938
|
+
queue=from_queue,
|
|
939
|
+
message_id=message_id,
|
|
940
|
+
operation="claim",
|
|
941
|
+
)
|
|
942
|
+
|
|
914
943
|
stored_message, lease_token = result[0], result[1]
|
|
915
944
|
reclaimed_attempts = _coerce_lua_message_attempts(result[2]) if len(result) > 2 else []
|
|
916
945
|
dead_lettered_attempts = _coerce_lua_message_attempts(result[3]) if len(result) > 3 else []
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import hashlib
|
|
3
3
|
import inspect
|
|
4
|
-
import json
|
|
5
4
|
import logging
|
|
6
5
|
import math
|
|
7
6
|
import time
|
|
@@ -29,6 +28,12 @@ from redis_message_queue._exceptions import (
|
|
|
29
28
|
RedisMessageQueueError,
|
|
30
29
|
_set_exception_context,
|
|
31
30
|
)
|
|
31
|
+
from redis_message_queue._payload_limits import (
|
|
32
|
+
serialize_dict_payload_with_limit,
|
|
33
|
+
validate_max_payload_depth,
|
|
34
|
+
validate_payload_limit_parameter,
|
|
35
|
+
validate_str_payload_size,
|
|
36
|
+
)
|
|
32
37
|
from redis_message_queue._queue_key_manager import QueueKeyManager, validate_callable_deduplication_key
|
|
33
38
|
from redis_message_queue._redis_cluster import (
|
|
34
39
|
plain_redis_cluster_client_error,
|
|
@@ -38,6 +43,7 @@ from redis_message_queue._redis_cluster import (
|
|
|
38
43
|
from redis_message_queue._stored_message import (
|
|
39
44
|
ClaimedMessage,
|
|
40
45
|
MessageData,
|
|
46
|
+
MessagePayload,
|
|
41
47
|
decode_stored_message,
|
|
42
48
|
extract_stored_message_id,
|
|
43
49
|
)
|
|
@@ -86,23 +92,24 @@ def _find_non_string_dict_keys(value: object) -> list[object]:
|
|
|
86
92
|
non_str_keys: list[object] = []
|
|
87
93
|
seen: set[int] = set()
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
stack = [value]
|
|
96
|
+
while stack:
|
|
97
|
+
current = stack.pop()
|
|
90
98
|
if not isinstance(current, (dict, list, tuple)):
|
|
91
|
-
|
|
99
|
+
continue
|
|
92
100
|
current_id = id(current)
|
|
93
101
|
if current_id in seen:
|
|
94
|
-
|
|
102
|
+
continue
|
|
95
103
|
seen.add(current_id)
|
|
96
104
|
if isinstance(current, dict):
|
|
105
|
+
children = []
|
|
97
106
|
for key, child in current.items():
|
|
98
107
|
if not isinstance(key, str):
|
|
99
108
|
non_str_keys.append(key)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
visit(value)
|
|
109
|
+
children.append(child)
|
|
110
|
+
stack.extend(reversed(children))
|
|
111
|
+
else:
|
|
112
|
+
stack.extend(reversed(current))
|
|
106
113
|
return non_str_keys
|
|
107
114
|
|
|
108
115
|
|
|
@@ -565,8 +572,10 @@ class RedisMessageQueue:
|
|
|
565
572
|
pending_overload_policy: Literal["raise", "drop_oldest", "block"] = "raise",
|
|
566
573
|
pending_overload_block_timeout_seconds: float = DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
|
|
567
574
|
key_separator: str = "::",
|
|
568
|
-
get_deduplication_key: Optional[Callable[[str |
|
|
575
|
+
get_deduplication_key: Optional[Callable[[MessagePayload], str | Awaitable[str]]] = None,
|
|
569
576
|
strict_payload_types: bool = False,
|
|
577
|
+
max_payload_bytes: int | None = None,
|
|
578
|
+
max_payload_depth: int | None = None,
|
|
570
579
|
interrupt: BaseGracefulInterruptHandler | None = None,
|
|
571
580
|
on_heartbeat_failure: Callable[[], Awaitable[None] | None] | None = None,
|
|
572
581
|
on_event: Callable[[QueueEvent], Awaitable[None]] | None = None,
|
|
@@ -601,6 +610,10 @@ class RedisMessageQueue:
|
|
|
601
610
|
sets, bytes, and datetime objects with a path-aware ``TypeError``.
|
|
602
611
|
The default ``False`` preserves the existing ``json.dumps`` behavior.
|
|
603
612
|
|
|
613
|
+
``max_payload_bytes`` and ``max_payload_depth`` default to ``None``
|
|
614
|
+
(unbounded). Set positive integers to reject oversized serialized
|
|
615
|
+
payloads or overly deep dict/list payload trees before enqueue.
|
|
616
|
+
|
|
604
617
|
``max_pending_length`` defaults to ``None`` (unbounded). Set it to a
|
|
605
618
|
positive integer to cap pending-list depth during publish.
|
|
606
619
|
|
|
@@ -654,6 +667,8 @@ class RedisMessageQueue:
|
|
|
654
667
|
f"'strict_payload_types' must be a bool, got {type(strict_payload_types).__name__}"
|
|
655
668
|
" (use True or False, not 1/0)"
|
|
656
669
|
)
|
|
670
|
+
max_payload_bytes = validate_payload_limit_parameter("max_payload_bytes", max_payload_bytes)
|
|
671
|
+
max_payload_depth = validate_payload_limit_parameter("max_payload_depth", max_payload_depth)
|
|
657
672
|
if max_completed_length is not None:
|
|
658
673
|
if not isinstance(max_completed_length, int) or isinstance(max_completed_length, bool):
|
|
659
674
|
bool_hint = " (use True or False, not 1/0)" if isinstance(max_completed_length, bool) else ""
|
|
@@ -702,7 +717,8 @@ class RedisMessageQueue:
|
|
|
702
717
|
if get_deduplication_key is not None and not callable(get_deduplication_key):
|
|
703
718
|
raise TypeError(
|
|
704
719
|
f"'get_deduplication_key' must be callable, got {type(get_deduplication_key).__name__}."
|
|
705
|
-
" Expected a function that takes the message (
|
|
720
|
+
" Expected a function that takes the message (MessagePayload) and returns a str"
|
|
721
|
+
" (or an awaitable thereof)."
|
|
706
722
|
" Example: get_deduplication_key=lambda msg: msg['user_id']"
|
|
707
723
|
)
|
|
708
724
|
validate_dedup_configuration(
|
|
@@ -750,6 +766,8 @@ class RedisMessageQueue:
|
|
|
750
766
|
self._max_delivery_count = max_delivery_count
|
|
751
767
|
self._get_deduplication_key = get_deduplication_key
|
|
752
768
|
self._strict_payload_types = strict_payload_types
|
|
769
|
+
self._max_payload_bytes = max_payload_bytes
|
|
770
|
+
self._max_payload_depth = max_payload_depth
|
|
753
771
|
self._heartbeat_interval_seconds = None
|
|
754
772
|
self._warned_no_lease_for_heartbeat = False
|
|
755
773
|
self._requires_claimed_message = False
|
|
@@ -951,7 +969,7 @@ class RedisMessageQueue:
|
|
|
951
969
|
raise plain_redis_cluster_client_error(type(client).__name__)
|
|
952
970
|
self._plain_redis_cluster_probe_client = None
|
|
953
971
|
|
|
954
|
-
async def publish(self, message:
|
|
972
|
+
async def publish(self, message: MessagePayload) -> bool:
|
|
955
973
|
"""Publish a message.
|
|
956
974
|
|
|
957
975
|
Dict messages are serialized via ``json.dumps(message, sort_keys=True)``.
|
|
@@ -978,7 +996,7 @@ class RedisMessageQueue:
|
|
|
978
996
|
raise QueueDrainedError("queue is drained", queue=self._queue_name, operation="drain")
|
|
979
997
|
return await self._publish_unlocked(message)
|
|
980
998
|
|
|
981
|
-
async def _publish_unlocked(self, message:
|
|
999
|
+
async def _publish_unlocked(self, message: MessagePayload) -> bool:
|
|
982
1000
|
started_at = time.perf_counter()
|
|
983
1001
|
try:
|
|
984
1002
|
await self._ensure_plain_redis_client_is_not_cluster()
|
|
@@ -993,8 +1011,10 @@ class RedisMessageQueue:
|
|
|
993
1011
|
)
|
|
994
1012
|
if self._strict_payload_types:
|
|
995
1013
|
_validate_strict_payload_types(message)
|
|
996
|
-
|
|
1014
|
+
validate_max_payload_depth(message, self._max_payload_depth)
|
|
1015
|
+
message_str = serialize_dict_payload_with_limit(message, self._max_payload_bytes)
|
|
997
1016
|
else:
|
|
1017
|
+
validate_str_payload_size(message, self._max_payload_bytes)
|
|
998
1018
|
message_str = message
|
|
999
1019
|
|
|
1000
1020
|
if not self._deduplication:
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/redis_message_queue.py
RENAMED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import inspect
|
|
3
|
-
import json
|
|
4
3
|
import logging
|
|
5
4
|
import math
|
|
6
5
|
import threading
|
|
@@ -30,6 +29,12 @@ from redis_message_queue._exceptions import (
|
|
|
30
29
|
RedisMessageQueueError,
|
|
31
30
|
_set_exception_context,
|
|
32
31
|
)
|
|
32
|
+
from redis_message_queue._payload_limits import (
|
|
33
|
+
serialize_dict_payload_with_limit,
|
|
34
|
+
validate_max_payload_depth,
|
|
35
|
+
validate_payload_limit_parameter,
|
|
36
|
+
validate_str_payload_size,
|
|
37
|
+
)
|
|
33
38
|
from redis_message_queue._queue_key_manager import QueueKeyManager, validate_callable_deduplication_key
|
|
34
39
|
from redis_message_queue._redis_cluster import (
|
|
35
40
|
plain_redis_cluster_client_error,
|
|
@@ -40,6 +45,7 @@ from redis_message_queue._redis_gateway import RedisGateway
|
|
|
40
45
|
from redis_message_queue._stored_message import (
|
|
41
46
|
ClaimedMessage,
|
|
42
47
|
MessageData,
|
|
48
|
+
MessagePayload,
|
|
43
49
|
decode_stored_message,
|
|
44
50
|
extract_stored_message_id,
|
|
45
51
|
)
|
|
@@ -89,23 +95,24 @@ def _find_non_string_dict_keys(value: object) -> list[object]:
|
|
|
89
95
|
non_str_keys: list[object] = []
|
|
90
96
|
seen: set[int] = set()
|
|
91
97
|
|
|
92
|
-
|
|
98
|
+
stack = [value]
|
|
99
|
+
while stack:
|
|
100
|
+
current = stack.pop()
|
|
93
101
|
if not isinstance(current, (dict, list, tuple)):
|
|
94
|
-
|
|
102
|
+
continue
|
|
95
103
|
current_id = id(current)
|
|
96
104
|
if current_id in seen:
|
|
97
|
-
|
|
105
|
+
continue
|
|
98
106
|
seen.add(current_id)
|
|
99
107
|
if isinstance(current, dict):
|
|
108
|
+
children = []
|
|
100
109
|
for key, child in current.items():
|
|
101
110
|
if not isinstance(key, str):
|
|
102
111
|
non_str_keys.append(key)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
visit(value)
|
|
112
|
+
children.append(child)
|
|
113
|
+
stack.extend(reversed(children))
|
|
114
|
+
else:
|
|
115
|
+
stack.extend(reversed(current))
|
|
109
116
|
return non_str_keys
|
|
110
117
|
|
|
111
118
|
|
|
@@ -517,8 +524,10 @@ class RedisMessageQueue:
|
|
|
517
524
|
pending_overload_policy: Literal["raise", "drop_oldest", "block"] = "raise",
|
|
518
525
|
pending_overload_block_timeout_seconds: float = DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS,
|
|
519
526
|
key_separator: str = "::",
|
|
520
|
-
get_deduplication_key: Optional[Callable[[
|
|
527
|
+
get_deduplication_key: Optional[Callable[[MessagePayload], str]] = None,
|
|
521
528
|
strict_payload_types: bool = False,
|
|
529
|
+
max_payload_bytes: int | None = None,
|
|
530
|
+
max_payload_depth: int | None = None,
|
|
522
531
|
interrupt: BaseGracefulInterruptHandler | None = None,
|
|
523
532
|
on_heartbeat_failure: Callable[[], None] | None = None,
|
|
524
533
|
on_event: Callable[[QueueEvent], None] | None = None,
|
|
@@ -553,6 +562,10 @@ class RedisMessageQueue:
|
|
|
553
562
|
sets, bytes, and datetime objects with a path-aware ``TypeError``.
|
|
554
563
|
The default ``False`` preserves the existing ``json.dumps`` behavior.
|
|
555
564
|
|
|
565
|
+
``max_payload_bytes`` and ``max_payload_depth`` default to ``None``
|
|
566
|
+
(unbounded). Set positive integers to reject oversized serialized
|
|
567
|
+
payloads or overly deep dict/list payload trees before enqueue.
|
|
568
|
+
|
|
556
569
|
``max_pending_length`` defaults to ``None`` (unbounded). Set it to a
|
|
557
570
|
positive integer to cap pending-list depth during publish.
|
|
558
571
|
|
|
@@ -605,6 +618,8 @@ class RedisMessageQueue:
|
|
|
605
618
|
f"'strict_payload_types' must be a bool, got {type(strict_payload_types).__name__}"
|
|
606
619
|
" (use True or False, not 1/0)"
|
|
607
620
|
)
|
|
621
|
+
max_payload_bytes = validate_payload_limit_parameter("max_payload_bytes", max_payload_bytes)
|
|
622
|
+
max_payload_depth = validate_payload_limit_parameter("max_payload_depth", max_payload_depth)
|
|
608
623
|
if max_completed_length is not None:
|
|
609
624
|
if not isinstance(max_completed_length, int) or isinstance(max_completed_length, bool):
|
|
610
625
|
bool_hint = " (use True or False, not 1/0)" if isinstance(max_completed_length, bool) else ""
|
|
@@ -653,7 +668,7 @@ class RedisMessageQueue:
|
|
|
653
668
|
if get_deduplication_key is not None and not callable(get_deduplication_key):
|
|
654
669
|
raise TypeError(
|
|
655
670
|
f"'get_deduplication_key' must be callable, got {type(get_deduplication_key).__name__}."
|
|
656
|
-
" Expected a function that takes the message (
|
|
671
|
+
" Expected a function that takes the message (MessagePayload) and returns a str."
|
|
657
672
|
" Example: get_deduplication_key=lambda msg: msg['user_id']"
|
|
658
673
|
)
|
|
659
674
|
if get_deduplication_key is not None and is_async_callable(get_deduplication_key):
|
|
@@ -713,6 +728,8 @@ class RedisMessageQueue:
|
|
|
713
728
|
self._max_delivery_count = max_delivery_count
|
|
714
729
|
self._get_deduplication_key = get_deduplication_key
|
|
715
730
|
self._strict_payload_types = strict_payload_types
|
|
731
|
+
self._max_payload_bytes = max_payload_bytes
|
|
732
|
+
self._max_payload_depth = max_payload_depth
|
|
716
733
|
self._heartbeat_interval_seconds = None
|
|
717
734
|
self._warned_no_lease_for_heartbeat = False
|
|
718
735
|
self._requires_claimed_message = False
|
|
@@ -900,7 +917,7 @@ class RedisMessageQueue:
|
|
|
900
917
|
wrapped_error.__cause__ = raw_error
|
|
901
918
|
return wrapped_error
|
|
902
919
|
|
|
903
|
-
def publish(self, message:
|
|
920
|
+
def publish(self, message: MessagePayload) -> bool:
|
|
904
921
|
"""Publish a message.
|
|
905
922
|
|
|
906
923
|
Dict messages are serialized via ``json.dumps(message, sort_keys=True)``.
|
|
@@ -927,7 +944,7 @@ class RedisMessageQueue:
|
|
|
927
944
|
raise QueueDrainedError("queue is drained", queue=self._queue_name, operation="drain")
|
|
928
945
|
return self._publish_unlocked(message)
|
|
929
946
|
|
|
930
|
-
def _publish_unlocked(self, message:
|
|
947
|
+
def _publish_unlocked(self, message: MessagePayload) -> bool:
|
|
931
948
|
started_at = time.perf_counter()
|
|
932
949
|
try:
|
|
933
950
|
if not isinstance(message, (str, dict)):
|
|
@@ -941,8 +958,10 @@ class RedisMessageQueue:
|
|
|
941
958
|
)
|
|
942
959
|
if self._strict_payload_types:
|
|
943
960
|
_validate_strict_payload_types(message)
|
|
944
|
-
|
|
961
|
+
validate_max_payload_depth(message, self._max_payload_depth)
|
|
962
|
+
message_str = serialize_dict_payload_with_limit(message, self._max_payload_bytes)
|
|
945
963
|
else:
|
|
964
|
+
validate_str_payload_size(message, self._max_payload_bytes)
|
|
946
965
|
message_str = message
|
|
947
966
|
|
|
948
967
|
if not self._deduplication:
|
|
File without changes
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_callable_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
{redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_redis_cluster.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|