rillkit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rillkit-0.1.0/.gitignore +12 -0
- rillkit-0.1.0/LICENSE +21 -0
- rillkit-0.1.0/PKG-INFO +81 -0
- rillkit-0.1.0/README.md +52 -0
- rillkit-0.1.0/pyproject.toml +57 -0
- rillkit-0.1.0/src/rillkit/__init__.py +25 -0
- rillkit-0.1.0/src/rillkit/ack.py +18 -0
- rillkit-0.1.0/src/rillkit/broker.py +271 -0
- rillkit-0.1.0/src/rillkit/codec.py +28 -0
- rillkit-0.1.0/src/rillkit/consumer.py +343 -0
- rillkit-0.1.0/src/rillkit/exceptions.py +28 -0
- rillkit-0.1.0/src/rillkit/message.py +74 -0
- rillkit-0.1.0/src/rillkit/middleware.py +75 -0
- rillkit-0.1.0/src/rillkit/py.typed +0 -0
- rillkit-0.1.0/src/rillkit/settings.py +39 -0
- rillkit-0.1.0/src/rillkit/stream.py +29 -0
- rillkit-0.1.0/tests/conftest.py +56 -0
- rillkit-0.1.0/tests/helpers.py +43 -0
- rillkit-0.1.0/tests/test_consume.py +430 -0
- rillkit-0.1.0/tests/test_lifecycle.py +266 -0
- rillkit-0.1.0/tests/test_middleware.py +342 -0
- rillkit-0.1.0/tests/test_publish.py +78 -0
- rillkit-0.1.0/tests/test_publisher_decorator.py +127 -0
- rillkit-0.1.0/tests/test_retries.py +300 -0
- rillkit-0.1.0/tests/test_settings.py +79 -0
rillkit-0.1.0/.gitignore
ADDED
rillkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nimrod Maina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
rillkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rillkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple publish and consume for Redis Streams: consumer groups, retries, DLQ, ack policies and middleware.
|
|
5
|
+
Project-URL: Homepage, https://nimzymaina.github.io/rillkit/
|
|
6
|
+
Project-URL: Documentation, https://nimzymaina.github.io/rillkit/
|
|
7
|
+
Project-URL: Repository, https://github.com/NimzyMaina/rillkit
|
|
8
|
+
Project-URL: Issues, https://github.com/NimzyMaina/rillkit/issues
|
|
9
|
+
Author: Nimrod Maina
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: consumer-group,messaging,queue,redis,streams,worker
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: redis>=5.0
|
|
26
|
+
Provides-Extra: pydantic
|
|
27
|
+
Requires-Dist: pydantic>=2.0; extra == 'pydantic'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# RillKit
|
|
31
|
+
|
|
32
|
+
Simple publish and consume for Redis Streams. Consumer groups, retries with backoff, a dead-letter
|
|
33
|
+
queue, ack policies, middleware and lifecycle hooks, without the boilerplate.
|
|
34
|
+
|
|
35
|
+
**Docs:** https://nimzymaina.github.io/rillkit/
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install rillkit # plain dict bodies
|
|
39
|
+
pip install "rillkit[pydantic]" # optional typed bodies
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from rillkit import Broker, Stream
|
|
44
|
+
|
|
45
|
+
orders = Stream("orders", group="workers")
|
|
46
|
+
broker = Broker.from_env() # REDIS_URL, or REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_SSL
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@broker.subscribe(orders, max_retries=3)
|
|
50
|
+
def handle(order: dict):
|
|
51
|
+
print("got", order)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
broker.publish(orders, {"id": 1})
|
|
56
|
+
broker.run() # blocks; Ctrl-C / SIGTERM finishes the current message and exits
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- Consumer groups: group creation, `XREADGROUP`, and `XAUTOCLAIM`-style takeover of stuck messages
|
|
62
|
+
- At-least-once delivery with retries (`max_retries`, optional exponential `backoff`)
|
|
63
|
+
- Dead-letter queue with the original id, error and delivery count; `broker.replay_dlq()`
|
|
64
|
+
- `msg.nack()` to retry, `msg.reject()` (or `RejectMessage`) to skip retries and go straight to the DLQ
|
|
65
|
+
- `AckPolicy`: `NACK_ON_ERROR` (default), `ACK_ALWAYS`, `ACK_FIRST`, `MANUAL`
|
|
66
|
+
- Plain dicts by default, Pydantic models when you annotate the handler with one
|
|
67
|
+
- Middleware hooks (`on_receive`, `consume_scope`, `publish_scope`, `after_processed`) and an `Idempotent` middleware
|
|
68
|
+
- `on_startup`, `after_startup`, `on_shutdown`, `after_shutdown` hooks and graceful SIGTERM shutdown
|
|
69
|
+
- Worker heartbeats: `broker.active_workers(stream)` includes idle workers
|
|
70
|
+
- Stack `@broker.publisher(...)` under `@broker.subscribe(...)` to publish a handler's return value
|
|
71
|
+
- The wire format is a single `data` field holding JSON, so plain redis-py producers interoperate
|
|
72
|
+
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv sync --group dev
|
|
77
|
+
REDIS_URL=redis://localhost:6379/15 uv run pytest # needs a Redis
|
|
78
|
+
npm install && npm run docs:dev # docs
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
MIT licensed.
|
rillkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# RillKit
|
|
2
|
+
|
|
3
|
+
Simple publish and consume for Redis Streams. Consumer groups, retries with backoff, a dead-letter
|
|
4
|
+
queue, ack policies, middleware and lifecycle hooks, without the boilerplate.
|
|
5
|
+
|
|
6
|
+
**Docs:** https://nimzymaina.github.io/rillkit/
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install rillkit # plain dict bodies
|
|
10
|
+
pip install "rillkit[pydantic]" # optional typed bodies
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from rillkit import Broker, Stream
|
|
15
|
+
|
|
16
|
+
orders = Stream("orders", group="workers")
|
|
17
|
+
broker = Broker.from_env() # REDIS_URL, or REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_SSL
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@broker.subscribe(orders, max_retries=3)
|
|
21
|
+
def handle(order: dict):
|
|
22
|
+
print("got", order)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
broker.publish(orders, {"id": 1})
|
|
27
|
+
broker.run() # blocks; Ctrl-C / SIGTERM finishes the current message and exits
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Consumer groups: group creation, `XREADGROUP`, and `XAUTOCLAIM`-style takeover of stuck messages
|
|
33
|
+
- At-least-once delivery with retries (`max_retries`, optional exponential `backoff`)
|
|
34
|
+
- Dead-letter queue with the original id, error and delivery count; `broker.replay_dlq()`
|
|
35
|
+
- `msg.nack()` to retry, `msg.reject()` (or `RejectMessage`) to skip retries and go straight to the DLQ
|
|
36
|
+
- `AckPolicy`: `NACK_ON_ERROR` (default), `ACK_ALWAYS`, `ACK_FIRST`, `MANUAL`
|
|
37
|
+
- Plain dicts by default, Pydantic models when you annotate the handler with one
|
|
38
|
+
- Middleware hooks (`on_receive`, `consume_scope`, `publish_scope`, `after_processed`) and an `Idempotent` middleware
|
|
39
|
+
- `on_startup`, `after_startup`, `on_shutdown`, `after_shutdown` hooks and graceful SIGTERM shutdown
|
|
40
|
+
- Worker heartbeats: `broker.active_workers(stream)` includes idle workers
|
|
41
|
+
- Stack `@broker.publisher(...)` under `@broker.subscribe(...)` to publish a handler's return value
|
|
42
|
+
- The wire format is a single `data` field holding JSON, so plain redis-py producers interoperate
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv sync --group dev
|
|
48
|
+
REDIS_URL=redis://localhost:6379/15 uv run pytest # needs a Redis
|
|
49
|
+
npm install && npm run docs:dev # docs
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
MIT licensed.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rillkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Simple publish and consume for Redis Streams: consumer groups, retries, DLQ, ack policies and middleware."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Nimrod Maina" }]
|
|
13
|
+
keywords = ["redis", "streams", "consumer-group", "queue", "messaging", "worker"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["redis>=5.0"]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
pydantic = ["pydantic>=2.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://nimzymaina.github.io/rillkit/"
|
|
34
|
+
Documentation = "https://nimzymaina.github.io/rillkit/"
|
|
35
|
+
Repository = "https://github.com/NimzyMaina/rillkit"
|
|
36
|
+
Issues = "https://github.com/NimzyMaina/rillkit/issues"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["pytest>=8", "pytest-timeout>=2", "pydantic>=2", "ruff>=0.6", "mypy>=1.10", "build", "twine"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/rillkit"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
timeout = 60
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
src = ["src", "tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
57
|
+
ignore = ["UP007", "UP045"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""rillkit: simple publish and consume for Redis Streams."""
|
|
2
|
+
|
|
3
|
+
from .ack import AckPolicy
|
|
4
|
+
from .broker import Broker, Publisher
|
|
5
|
+
from .codec import Codec, JsonCodec
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AckMessage,
|
|
8
|
+
DecodeError,
|
|
9
|
+
NackMessage,
|
|
10
|
+
PublishError,
|
|
11
|
+
RejectMessage,
|
|
12
|
+
RillkitError,
|
|
13
|
+
)
|
|
14
|
+
from .message import Message
|
|
15
|
+
from .middleware import BaseMiddleware, Idempotent, PublishCommand
|
|
16
|
+
from .settings import Settings
|
|
17
|
+
from .stream import Stream
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AckMessage", "AckPolicy", "BaseMiddleware", "Broker", "Codec", "DecodeError", "Idempotent",
|
|
23
|
+
"JsonCodec", "Message", "NackMessage", "PublishCommand", "PublishError", "Publisher",
|
|
24
|
+
"RejectMessage", "RillkitError", "Settings", "Stream",
|
|
25
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AckPolicy(str, Enum):
|
|
5
|
+
"""What happens to a message when its handler finishes.
|
|
6
|
+
|
|
7
|
+
NACK_ON_ERROR Ack when the handler returns, nack when it raises. This is the default.
|
|
8
|
+
ACK_ALWAYS Ack when the handler returns or raises. Failures are logged and dropped, never
|
|
9
|
+
retried.
|
|
10
|
+
ACK_FIRST Ack before the handler runs. At-most-once: a crash loses the message.
|
|
11
|
+
MANUAL rillkit never acks. The handler must call ``msg.ack()``. If it returns without
|
|
12
|
+
acking, or raises, the message stays pending and is retried.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
NACK_ON_ERROR = "nack_on_error"
|
|
16
|
+
ACK_ALWAYS = "ack_always"
|
|
17
|
+
ACK_FIRST = "ack_first"
|
|
18
|
+
MANUAL = "manual"
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from functools import partial
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
import redis
|
|
10
|
+
|
|
11
|
+
from .ack import AckPolicy
|
|
12
|
+
from .consumer import HEARTBEAT_PREFIX, Subscriber, _text
|
|
13
|
+
from .exceptions import PublishError
|
|
14
|
+
from .middleware import BaseMiddleware, PublishCommand
|
|
15
|
+
from .settings import Settings
|
|
16
|
+
from .stream import Stream, StreamLike, as_stream
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("rillkit")
|
|
19
|
+
|
|
20
|
+
Hook = Callable[[], Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Publisher:
|
|
24
|
+
"""Returned by ``broker.publisher(stream)``. Use it as a decorator to publish a handler's return
|
|
25
|
+
value, or call ``.publish(body)`` directly."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, broker: "Broker", stream: Stream, maxlen: Optional[int] = None) -> None:
|
|
28
|
+
self.broker = broker
|
|
29
|
+
self.stream = stream
|
|
30
|
+
self.maxlen = maxlen
|
|
31
|
+
|
|
32
|
+
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
|
|
33
|
+
publishers = list(getattr(func, "_rillkit_publishers", ()))
|
|
34
|
+
publishers.append(self)
|
|
35
|
+
func._rillkit_publishers = publishers # type: ignore[attr-defined]
|
|
36
|
+
return func
|
|
37
|
+
|
|
38
|
+
def publish(self, body: Any) -> str:
|
|
39
|
+
return self.broker.publish(self.stream, body, maxlen=self.maxlen)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Broker:
|
|
43
|
+
"""Owns the Redis client, the subscribers and the lifecycle hooks.
|
|
44
|
+
|
|
45
|
+
broker = Broker.from_env()
|
|
46
|
+
|
|
47
|
+
@broker.subscribe(Stream("orders", group="workers"))
|
|
48
|
+
def handle(order: dict): ...
|
|
49
|
+
|
|
50
|
+
broker.publish("orders", {"id": 1})
|
|
51
|
+
broker.run()
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
url: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
client: Optional["redis.Redis"] = None,
|
|
59
|
+
settings: Optional[Settings] = None,
|
|
60
|
+
middlewares: Sequence[BaseMiddleware] = (),
|
|
61
|
+
) -> None:
|
|
62
|
+
if client is None:
|
|
63
|
+
client = (settings or Settings(url=url)).client()
|
|
64
|
+
self.client = client
|
|
65
|
+
self.middlewares: list[BaseMiddleware] = []
|
|
66
|
+
for mw in middlewares:
|
|
67
|
+
self.use(mw)
|
|
68
|
+
self.subscribers: list[Subscriber] = []
|
|
69
|
+
self._hooks: dict[str, list[Hook]] = {
|
|
70
|
+
"on_startup": [], "after_startup": [], "on_shutdown": [], "after_shutdown": [],
|
|
71
|
+
}
|
|
72
|
+
self._stop = threading.Event()
|
|
73
|
+
self._stop_lock = threading.Lock()
|
|
74
|
+
self._stopped = False
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_env(cls, prefix: str = "REDIS_", **kwargs: Any) -> "Broker":
|
|
78
|
+
return cls(settings=Settings.from_env(prefix), **kwargs)
|
|
79
|
+
|
|
80
|
+
# middleware
|
|
81
|
+
|
|
82
|
+
def use(self, middleware: BaseMiddleware) -> BaseMiddleware:
|
|
83
|
+
middleware.broker = self
|
|
84
|
+
self.middlewares.append(middleware)
|
|
85
|
+
return middleware
|
|
86
|
+
|
|
87
|
+
# subscribing and publishing
|
|
88
|
+
|
|
89
|
+
def subscribe(
|
|
90
|
+
self,
|
|
91
|
+
stream: StreamLike,
|
|
92
|
+
*,
|
|
93
|
+
group: Optional[str] = None,
|
|
94
|
+
ack_policy: AckPolicy = AckPolicy.NACK_ON_ERROR,
|
|
95
|
+
max_retries: Optional[int] = 3,
|
|
96
|
+
claim_idle_ms: int = 60_000,
|
|
97
|
+
backoff: float = 1.0,
|
|
98
|
+
max_backoff_ms: int = 15 * 60_000,
|
|
99
|
+
block_ms: int = 5000,
|
|
100
|
+
count: int = 10,
|
|
101
|
+
middlewares: Sequence[BaseMiddleware] = (),
|
|
102
|
+
consumer: Optional[str] = None,
|
|
103
|
+
heartbeat: bool = True,
|
|
104
|
+
heartbeat_ttl: int = 60,
|
|
105
|
+
group_start_id: str = "0",
|
|
106
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
107
|
+
"""Register a handler for a stream. The handler is returned unchanged."""
|
|
108
|
+
resolved = as_stream(stream)
|
|
109
|
+
if group:
|
|
110
|
+
resolved = Stream(resolved.name, group, resolved.dlq, resolved.maxlen, resolved.codec)
|
|
111
|
+
own = list(middlewares)
|
|
112
|
+
for m in own:
|
|
113
|
+
m.broker = self
|
|
114
|
+
|
|
115
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
116
|
+
self.subscribers.append(Subscriber(
|
|
117
|
+
self, resolved, func, ack_policy=ack_policy, max_retries=max_retries,
|
|
118
|
+
claim_idle_ms=claim_idle_ms, backoff=backoff, max_backoff_ms=max_backoff_ms,
|
|
119
|
+
block_ms=block_ms, count=count, middlewares=own,
|
|
120
|
+
consumer=consumer, heartbeat=heartbeat, heartbeat_ttl=heartbeat_ttl,
|
|
121
|
+
group_start_id=group_start_id,
|
|
122
|
+
))
|
|
123
|
+
return func
|
|
124
|
+
|
|
125
|
+
return decorator
|
|
126
|
+
|
|
127
|
+
def publisher(self, stream: StreamLike, *, maxlen: Optional[int] = None) -> Publisher:
|
|
128
|
+
return Publisher(self, as_stream(stream), maxlen)
|
|
129
|
+
|
|
130
|
+
def publish(
|
|
131
|
+
self,
|
|
132
|
+
stream: StreamLike,
|
|
133
|
+
body: Any,
|
|
134
|
+
*,
|
|
135
|
+
maxlen: Optional[int] = None,
|
|
136
|
+
approximate: bool = True,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Add one entry to the stream and return its id. Raises ``PublishError`` on failure."""
|
|
139
|
+
resolved = as_stream(stream)
|
|
140
|
+
cmd = PublishCommand(
|
|
141
|
+
resolved, body, maxlen if maxlen is not None else resolved.maxlen, approximate
|
|
142
|
+
)
|
|
143
|
+
call: Callable[[PublishCommand], str] = self._xadd
|
|
144
|
+
for mw in reversed(self.middlewares):
|
|
145
|
+
call = partial(mw.publish_scope, call)
|
|
146
|
+
return call(cmd)
|
|
147
|
+
|
|
148
|
+
def _xadd(self, cmd: PublishCommand) -> str:
|
|
149
|
+
try:
|
|
150
|
+
payload = cmd.stream.codec.encode(cmd.body)
|
|
151
|
+
entry_id = self.client.xadd(
|
|
152
|
+
cmd.stream.name, {"data": payload}, maxlen=cmd.maxlen, approximate=cmd.approximate
|
|
153
|
+
)
|
|
154
|
+
except (redis.RedisError, TypeError, ValueError) as e:
|
|
155
|
+
raise PublishError(f"could not publish to {cmd.stream.name}: {e}") from e
|
|
156
|
+
return _text(entry_id)
|
|
157
|
+
|
|
158
|
+
# lifecycle hooks
|
|
159
|
+
|
|
160
|
+
def _hook(self, name: str) -> Callable[[Hook], Hook]:
|
|
161
|
+
def register(func: Hook) -> Hook:
|
|
162
|
+
self._hooks[name].append(func)
|
|
163
|
+
return func
|
|
164
|
+
return register
|
|
165
|
+
|
|
166
|
+
def on_startup(self, func: Hook) -> Hook:
|
|
167
|
+
"""Runs first, before the connection is checked."""
|
|
168
|
+
return self._hook("on_startup")(func)
|
|
169
|
+
|
|
170
|
+
def after_startup(self, func: Hook) -> Hook:
|
|
171
|
+
"""Runs once groups exist and the consumer threads are running."""
|
|
172
|
+
return self._hook("after_startup")(func)
|
|
173
|
+
|
|
174
|
+
def on_shutdown(self, func: Hook) -> Hook:
|
|
175
|
+
"""Runs after the consumers finished their current message, before the client closes."""
|
|
176
|
+
return self._hook("on_shutdown")(func)
|
|
177
|
+
|
|
178
|
+
def after_shutdown(self, func: Hook) -> Hook:
|
|
179
|
+
"""Runs last, after the client is closed."""
|
|
180
|
+
return self._hook("after_shutdown")(func)
|
|
181
|
+
|
|
182
|
+
def _run_hooks(self, name: str) -> None:
|
|
183
|
+
for func in self._hooks[name]:
|
|
184
|
+
func()
|
|
185
|
+
|
|
186
|
+
# running
|
|
187
|
+
|
|
188
|
+
def start(self) -> None:
|
|
189
|
+
"""Start all consumers in background threads and return."""
|
|
190
|
+
self._stop.clear()
|
|
191
|
+
self._stopped = False
|
|
192
|
+
self._run_hooks("on_startup")
|
|
193
|
+
self.client.ping()
|
|
194
|
+
for sub in self.subscribers:
|
|
195
|
+
sub.setup()
|
|
196
|
+
for sub in self.subscribers:
|
|
197
|
+
sub.start()
|
|
198
|
+
self._run_hooks("after_startup")
|
|
199
|
+
|
|
200
|
+
def stop(self, timeout: Optional[float] = None) -> None:
|
|
201
|
+
"""Ask the consumers to stop, wait for the current messages to finish, then clean up.
|
|
202
|
+
|
|
203
|
+
Safe to call more than once or from another thread while ``run()`` is blocking: the shutdown
|
|
204
|
+
hooks and the client close happen only once per start."""
|
|
205
|
+
with self._stop_lock:
|
|
206
|
+
if self._stopped:
|
|
207
|
+
return
|
|
208
|
+
self._stopped = True
|
|
209
|
+
for sub in self.subscribers:
|
|
210
|
+
sub.stop()
|
|
211
|
+
for sub in self.subscribers:
|
|
212
|
+
sub.join(timeout)
|
|
213
|
+
try:
|
|
214
|
+
self._run_hooks("on_shutdown")
|
|
215
|
+
finally:
|
|
216
|
+
self.client.close()
|
|
217
|
+
self._run_hooks("after_shutdown")
|
|
218
|
+
self._stop.set()
|
|
219
|
+
|
|
220
|
+
def run(self, handle_signals: bool = True, shutdown_timeout: Optional[float] = None) -> None:
|
|
221
|
+
"""Start the consumers and block until SIGINT/SIGTERM (or ``stop()``) shuts them down.
|
|
222
|
+
|
|
223
|
+
The first signal finishes the current message and exits cleanly. A second signal exits now.
|
|
224
|
+
"""
|
|
225
|
+
requested = threading.Event()
|
|
226
|
+
previous = {}
|
|
227
|
+
|
|
228
|
+
def on_signal(signum: int, frame: Any) -> None:
|
|
229
|
+
if requested.is_set():
|
|
230
|
+
log.warning("second shutdown signal received, exiting now")
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
log.info("shutdown requested, finishing the current message before exiting")
|
|
233
|
+
requested.set()
|
|
234
|
+
|
|
235
|
+
if handle_signals and threading.current_thread() is threading.main_thread():
|
|
236
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
237
|
+
previous[sig] = signal.signal(sig, on_signal)
|
|
238
|
+
try:
|
|
239
|
+
self.start()
|
|
240
|
+
while not requested.wait(0.5):
|
|
241
|
+
if not any(s._thread and s._thread.is_alive() for s in self.subscribers):
|
|
242
|
+
break
|
|
243
|
+
self.stop(shutdown_timeout)
|
|
244
|
+
finally:
|
|
245
|
+
for sig, handler in previous.items():
|
|
246
|
+
signal.signal(sig, handler)
|
|
247
|
+
|
|
248
|
+
# operations
|
|
249
|
+
|
|
250
|
+
def active_workers(self, stream: StreamLike, group: Optional[str] = None) -> list[str]:
|
|
251
|
+
"""Consumer names with a live heartbeat. Unlike XINFO CONSUMERS this includes idle workers
|
|
252
|
+
that have not received a message yet."""
|
|
253
|
+
s = as_stream(stream)
|
|
254
|
+
prefix = f"{HEARTBEAT_PREFIX}{s.name}:{group or s.group}:"
|
|
255
|
+
return sorted(_text(k)[len(prefix):] for k in self.client.scan_iter(match=f"{prefix}*"))
|
|
256
|
+
|
|
257
|
+
def replay_dlq(self, stream: StreamLike, count: int = 100) -> int:
|
|
258
|
+
"""Move up to ``count`` entries from the DLQ back onto the stream. Returns how many."""
|
|
259
|
+
s = as_stream(stream)
|
|
260
|
+
moved = 0
|
|
261
|
+
rows: Any = self.client.xrange(s.dlq_name, count=count)
|
|
262
|
+
for entry_id, raw in rows:
|
|
263
|
+
fields: dict[Any, Any] = {
|
|
264
|
+
_text(k): _text(v) for k, v in raw.items() if not _text(k).startswith("x-")
|
|
265
|
+
}
|
|
266
|
+
pipe = self.client.pipeline()
|
|
267
|
+
pipe.xadd(s.name, fields)
|
|
268
|
+
pipe.xdel(s.dlq_name, entry_id)
|
|
269
|
+
pipe.execute()
|
|
270
|
+
moved += 1
|
|
271
|
+
return moved
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
4
|
+
from .exceptions import DecodeError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Codec(Protocol):
|
|
8
|
+
def encode(self, body: Any) -> str: ...
|
|
9
|
+
|
|
10
|
+
def decode(self, raw: str) -> Any: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonCodec:
|
|
14
|
+
"""JSON in the ``data`` field of the stream entry.
|
|
15
|
+
|
|
16
|
+
Pydantic models are dumped with ``model_dump_json``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def encode(self, body: Any) -> str:
|
|
20
|
+
if hasattr(body, "model_dump_json"):
|
|
21
|
+
return body.model_dump_json()
|
|
22
|
+
return json.dumps(body, default=str)
|
|
23
|
+
|
|
24
|
+
def decode(self, raw: str) -> Any:
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(raw)
|
|
27
|
+
except (TypeError, ValueError) as e:
|
|
28
|
+
raise DecodeError(f"body is not valid JSON: {e}") from e
|