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.
Files changed (26) hide show
  1. redis_message_queue-8.2.1/.gitignore +158 -0
  2. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/PKG-INFO +16 -20
  3. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/README.md +7 -7
  4. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/pyproject.toml +56 -17
  5. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/__init__.py +8 -1
  6. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_abstract_redis_gateway.py +14 -7
  7. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_config.py +13 -2
  8. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_exceptions.py +16 -0
  9. redis_message_queue-8.2.1/redis_message_queue/_payload_limits.py +72 -0
  10. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_queue_key_manager.py +2 -1
  11. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_redis_gateway.py +29 -0
  12. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_stored_message.py +1 -0
  13. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/__init__.py +8 -1
  14. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/_abstract_redis_gateway.py +14 -7
  15. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/_redis_gateway.py +29 -0
  16. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/asyncio/redis_message_queue.py +35 -15
  17. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/redis_message_queue.py +34 -15
  18. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/LICENSE +0 -0
  19. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_callable_utils.py +0 -0
  20. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_event.py +0 -0
  21. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/_redis_cluster.py +0 -0
  22. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/__init__.py +0 -0
  23. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/_event_driven.py +0 -0
  24. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  25. {redis_message_queue-8.1.0 → redis_message_queue-8.2.1}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  26. {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.0
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: redis,message-queue,deduplication,task-queue
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-Dist: redis (>=5.0.0,<8.0.0)
21
- Requires-Dist: tenacity (>=8.1.0)
22
- Project-URL: Homepage, https://github.com/Elijas/redis-message-queue
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
- [![PyPI Version](https://img.shields.io/badge/v8.0.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
26
+ [![PyPI Version](https://img.shields.io/pypi/v/redis-message-queue?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
30
27
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
31
28
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
32
29
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](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.0.0,<9.0.0"
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
- poetry run python -m examples.send_messages
1094
- poetry run python -m examples.send_messages
1090
+ uv run python -m examples.send_messages
1091
+ uv run python -m examples.send_messages
1095
1092
 
1096
1093
  # Three consumers
1097
- poetry run python -m examples.receive_messages
1098
- poetry run python -m examples.receive_messages
1099
- poetry run python -m examples.receive_messages
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
  ![GitHub Repo stars](https://img.shields.io/github/stars/elijas/redis-message-queue?style=flat&color=fcfcfc&labelColor=white&logo=github&logoColor=black&label=stars)
1103
-
@@ -1,6 +1,6 @@
1
1
  # redis-message-queue
2
2
 
3
- [![PyPI Version](https://img.shields.io/badge/v8.0.1-version?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
3
+ [![PyPI Version](https://img.shields.io/pypi/v/redis-message-queue?color=43cd0f&style=flat&label=pypi)](https://pypi.org/project/redis-message-queue)
4
4
  [![PyPI Downloads](https://img.shields.io/pypi/dm/redis-message-queue?color=43cd0f&style=flat&label=downloads)](https://pypistats.org/packages/redis-message-queue)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-43cd0f.svg?style=flat&label=license)](LICENSE)
6
6
  [![Maintained: yes](https://img.shields.io/badge/yes-43cd0f.svg?style=flat&label=maintained)](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.0.0,<9.0.0"
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
- poetry run python -m examples.send_messages
1068
- poetry run python -m examples.send_messages
1067
+ uv run python -m examples.send_messages
1068
+ uv run python -m examples.send_messages
1069
1069
 
1070
1070
  # Three consumers
1071
- poetry run python -m examples.receive_messages
1072
- poetry run python -m examples.receive_messages
1073
- poetry run python -m examples.receive_messages
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
  ![GitHub Repo stars](https://img.shields.io/github/stars/elijas/redis-message-queue?style=flat&color=fcfcfc&labelColor=white&logo=github&logoColor=black&label=stars)
@@ -1,11 +1,11 @@
1
- [tool.poetry]
1
+ [project]
2
2
  name = "redis-message-queue"
3
- version = "8.1.0"
3
+ version = "8.2.1"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
- authors = ["Elijas <4084885+Elijas@users.noreply.github.com>"]
5
+ authors = [{ name = "Elijas", email = "4084885+Elijas@users.noreply.github.com" }]
6
6
  readme = "README.md"
7
- license = "MIT"
8
- include = ["redis_message_queue/py.typed"]
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
- [tool.poetry.urls]
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
- [tool.poetry.dependencies]
26
- python = "^3.12"
27
- redis = ">=5.0.0,<8.0.0"
28
- tenacity = ">=8.1.0"
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.poetry.group.test.dependencies]
31
- pytest = "*"
32
- pytest-asyncio = "*"
33
- pytest-cov = "*"
34
- fakeredis = {version = "*", extras = ["lua"]}
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 = ["poetry-core"]
38
- build-backend = "poetry.core.masonry.api"
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 expose
16
- a ``message_visibility_timeout_seconds`` property (int or None). This is not
17
- abstract because it is configuration rather than protocol, but it is required
18
- when the queue is configured with ``heartbeat_interval_seconds``.
19
- Lease-capable custom gateways MUST expose this property; omitting it
20
- silently disables heartbeat validation and lease-token safety checks,
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
- local lease_token = tostring(redis.call('INCR', KEYS[5]))
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 false
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
+ )
@@ -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: str | dict) -> str:
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"
@@ -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 []
@@ -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")
@@ -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 expose
17
- a ``message_visibility_timeout_seconds`` property (int or None). This is not
18
- abstract because it is configuration rather than protocol, but it is required
19
- when the queue is configured with ``heartbeat_interval_seconds``.
20
- Lease-capable custom gateways MUST expose this property; omitting it
21
- silently disables heartbeat validation and lease-token safety checks,
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
- def visit(current: object) -> None:
95
+ stack = [value]
96
+ while stack:
97
+ current = stack.pop()
90
98
  if not isinstance(current, (dict, list, tuple)):
91
- return
99
+ continue
92
100
  current_id = id(current)
93
101
  if current_id in seen:
94
- return
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
- visit(child)
101
- return
102
- for child in current:
103
- visit(child)
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 | dict], str]] = None,
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 (str | dict) and returns a str (or an awaitable thereof)."
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: str | dict) -> bool:
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: str | dict) -> bool:
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
- message_str = json.dumps(message, sort_keys=True, allow_nan=False)
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:
@@ -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
- def visit(current: object) -> None:
98
+ stack = [value]
99
+ while stack:
100
+ current = stack.pop()
93
101
  if not isinstance(current, (dict, list, tuple)):
94
- return
102
+ continue
95
103
  current_id = id(current)
96
104
  if current_id in seen:
97
- return
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
- visit(child)
104
- return
105
- for child in current:
106
- visit(child)
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[[str | dict], str]] = None,
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 (str | dict) and returns a str."
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: str | dict) -> bool:
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: str | dict) -> bool:
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
- message_str = json.dumps(message, sort_keys=True, allow_nan=False)
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: