rillkit 0.1.0__py3-none-any.whl
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/__init__.py +25 -0
- rillkit/ack.py +18 -0
- rillkit/broker.py +271 -0
- rillkit/codec.py +28 -0
- rillkit/consumer.py +343 -0
- rillkit/exceptions.py +28 -0
- rillkit/message.py +74 -0
- rillkit/middleware.py +75 -0
- rillkit/py.typed +0 -0
- rillkit/settings.py +39 -0
- rillkit/stream.py +29 -0
- rillkit-0.1.0.dist-info/METADATA +81 -0
- rillkit-0.1.0.dist-info/RECORD +15 -0
- rillkit-0.1.0.dist-info/WHEEL +4 -0
- rillkit-0.1.0.dist-info/licenses/LICENSE +21 -0
rillkit/__init__.py
ADDED
|
@@ -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
|
+
]
|
rillkit/ack.py
ADDED
|
@@ -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"
|
rillkit/broker.py
ADDED
|
@@ -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
|
rillkit/codec.py
ADDED
|
@@ -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
|
rillkit/consumer.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from functools import partial
|
|
9
|
+
from typing import (
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Any,
|
|
12
|
+
Callable,
|
|
13
|
+
Optional,
|
|
14
|
+
get_type_hints,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
import redis
|
|
18
|
+
|
|
19
|
+
from .ack import AckPolicy
|
|
20
|
+
from .exceptions import AckMessage, DecodeError, NackMessage, RejectMessage
|
|
21
|
+
from .message import Message
|
|
22
|
+
from .middleware import BaseMiddleware
|
|
23
|
+
from .stream import Stream
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .broker import Broker
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("rillkit")
|
|
29
|
+
|
|
30
|
+
RETRY_BACKOFF_SECONDS = 5
|
|
31
|
+
HEARTBEAT_PREFIX = "rillkit:workers:"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _text(value: Any) -> str:
|
|
35
|
+
return value.decode() if isinstance(value, bytes) else value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _fields(raw: dict[Any, Any]) -> dict[str, str]:
|
|
39
|
+
return {_text(k): _text(v) for k, v in raw.items()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _is_model(annotation: Any) -> bool:
|
|
43
|
+
return inspect.isclass(annotation) and hasattr(annotation, "model_validate")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _plan(handler: Callable[..., Any]) -> list[tuple[str, str, Any]]:
|
|
47
|
+
"""Decide what each handler parameter receives: the ``Message``, the plain body, or a model."""
|
|
48
|
+
try:
|
|
49
|
+
hints = get_type_hints(handler)
|
|
50
|
+
except Exception:
|
|
51
|
+
hints = getattr(handler, "__annotations__", {})
|
|
52
|
+
plan: list[tuple[str, str, Any]] = []
|
|
53
|
+
bodies = 0
|
|
54
|
+
for name, param in inspect.signature(handler).parameters.items():
|
|
55
|
+
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
|
56
|
+
continue
|
|
57
|
+
ann = hints.get(name, param.annotation)
|
|
58
|
+
if ann is Message or (ann is param.empty and name in ("msg", "message")):
|
|
59
|
+
plan.append((name, "message", None))
|
|
60
|
+
elif _is_model(ann):
|
|
61
|
+
plan.append((name, "model", ann))
|
|
62
|
+
bodies += 1
|
|
63
|
+
else:
|
|
64
|
+
plan.append((name, "body", None))
|
|
65
|
+
bodies += 1
|
|
66
|
+
if bodies > 1:
|
|
67
|
+
raise TypeError(f"{handler!r} takes more than one body parameter")
|
|
68
|
+
return plan
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Subscriber:
|
|
72
|
+
"""Runs one consumer loop for one stream and group: claim stuck messages, then read new ones."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
broker: "Broker",
|
|
77
|
+
stream: Stream,
|
|
78
|
+
handler: Callable[..., Any],
|
|
79
|
+
*,
|
|
80
|
+
ack_policy: AckPolicy = AckPolicy.NACK_ON_ERROR,
|
|
81
|
+
max_retries: Optional[int] = 3,
|
|
82
|
+
claim_idle_ms: int = 60_000,
|
|
83
|
+
backoff: float = 1.0,
|
|
84
|
+
max_backoff_ms: int = 15 * 60_000,
|
|
85
|
+
block_ms: int = 5000,
|
|
86
|
+
count: int = 10,
|
|
87
|
+
middlewares: Sequence[BaseMiddleware] = (),
|
|
88
|
+
consumer: Optional[str] = None,
|
|
89
|
+
heartbeat: bool = True,
|
|
90
|
+
heartbeat_ttl: int = 60,
|
|
91
|
+
group_start_id: str = "0",
|
|
92
|
+
) -> None:
|
|
93
|
+
if not stream.group:
|
|
94
|
+
raise ValueError(f"stream {stream.name!r} needs a group to be consumed")
|
|
95
|
+
self.broker = broker
|
|
96
|
+
self.stream = stream
|
|
97
|
+
self.group: str = stream.group
|
|
98
|
+
self.handler = handler
|
|
99
|
+
self.ack_policy = AckPolicy(ack_policy)
|
|
100
|
+
self.max_retries = max_retries
|
|
101
|
+
self.claim_idle_ms = claim_idle_ms
|
|
102
|
+
self.backoff = backoff
|
|
103
|
+
self.max_backoff_ms = max_backoff_ms
|
|
104
|
+
self.block_ms = block_ms
|
|
105
|
+
self.count = count
|
|
106
|
+
self.own_middlewares = list(middlewares)
|
|
107
|
+
self.consumer = consumer or f"{stream.group}-{socket.gethostname()}-{os.getpid()}"
|
|
108
|
+
self.heartbeat = heartbeat
|
|
109
|
+
self.heartbeat_ttl = heartbeat_ttl
|
|
110
|
+
self.group_start_id = group_start_id
|
|
111
|
+
self._plan = _plan(handler)
|
|
112
|
+
self._stop = threading.Event()
|
|
113
|
+
self._thread: Optional[threading.Thread] = None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def client(self) -> "redis.Redis":
|
|
117
|
+
return self.broker.client
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def middlewares(self) -> list[BaseMiddleware]:
|
|
121
|
+
"""Broker-wide middleware first (outermost), then this subscriber's own."""
|
|
122
|
+
return [*self.broker.middlewares, *self.own_middlewares]
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def heartbeat_key(self) -> str:
|
|
126
|
+
return f"{HEARTBEAT_PREFIX}{self.stream.name}:{self.group}:{self.consumer}"
|
|
127
|
+
|
|
128
|
+
# lifecycle
|
|
129
|
+
|
|
130
|
+
def setup(self) -> None:
|
|
131
|
+
try:
|
|
132
|
+
self.client.xgroup_create(
|
|
133
|
+
self.stream.name, self.group, id=self.group_start_id, mkstream=True
|
|
134
|
+
)
|
|
135
|
+
log.debug("created group %s on %s", self.group, self.stream.name)
|
|
136
|
+
except redis.exceptions.ResponseError as e:
|
|
137
|
+
if "BUSYGROUP" not in str(e):
|
|
138
|
+
raise
|
|
139
|
+
|
|
140
|
+
def start(self) -> None:
|
|
141
|
+
self._thread = threading.Thread(
|
|
142
|
+
target=self._run, name=f"rillkit-{self.stream.name}", daemon=True
|
|
143
|
+
)
|
|
144
|
+
self._thread.start()
|
|
145
|
+
|
|
146
|
+
def stop(self) -> None:
|
|
147
|
+
self._stop.set()
|
|
148
|
+
|
|
149
|
+
def join(self, timeout: Optional[float] = None) -> None:
|
|
150
|
+
if self._thread:
|
|
151
|
+
self._thread.join(timeout)
|
|
152
|
+
|
|
153
|
+
def _run(self) -> None:
|
|
154
|
+
log.info(
|
|
155
|
+
"consumer started | stream: %s | group: %s | consumer: %s | policy: %s"
|
|
156
|
+
" | max retries: %s",
|
|
157
|
+
self.stream.name, self.group, self.consumer, self.ack_policy.value,
|
|
158
|
+
self.max_retries,
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
while not self._stop.is_set():
|
|
162
|
+
self._beat()
|
|
163
|
+
try:
|
|
164
|
+
for msg in self._claim():
|
|
165
|
+
self._process(msg)
|
|
166
|
+
for msg in self._read():
|
|
167
|
+
if self._stop.is_set():
|
|
168
|
+
break # unread leftovers stay pending, claimed after the idle time
|
|
169
|
+
self._process(msg)
|
|
170
|
+
except redis.exceptions.ResponseError as e:
|
|
171
|
+
if "NOGROUP" in str(e):
|
|
172
|
+
self.setup()
|
|
173
|
+
else:
|
|
174
|
+
self._loop_error(e)
|
|
175
|
+
except Exception as e: # redis or other loop-level error: keep the worker alive
|
|
176
|
+
self._loop_error(e)
|
|
177
|
+
finally:
|
|
178
|
+
if self.heartbeat:
|
|
179
|
+
try:
|
|
180
|
+
self.client.delete(self.heartbeat_key)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
log.error("could not remove heartbeat key: %s", e)
|
|
183
|
+
log.info(
|
|
184
|
+
"consumer stopped | stream: %s | consumer: %s", self.stream.name, self.consumer
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def _loop_error(self, e: Exception) -> None:
|
|
188
|
+
log.error("consumer loop error, retrying in %s s: %s", RETRY_BACKOFF_SECONDS, e)
|
|
189
|
+
self._stop.wait(RETRY_BACKOFF_SECONDS)
|
|
190
|
+
|
|
191
|
+
def _beat(self) -> None:
|
|
192
|
+
if not self.heartbeat:
|
|
193
|
+
return
|
|
194
|
+
try:
|
|
195
|
+
self.client.set(self.heartbeat_key, int(time.time()), ex=self.heartbeat_ttl)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log.error("heartbeat failed: %s", e)
|
|
198
|
+
|
|
199
|
+
# reading
|
|
200
|
+
|
|
201
|
+
def _message(self, id: Any, raw: dict[Any, Any], deliveries: int) -> Message:
|
|
202
|
+
return Message(_text(id), self.stream, _fields(raw), deliveries, self.consumer, self.client)
|
|
203
|
+
|
|
204
|
+
def _read(self) -> list[Message]:
|
|
205
|
+
result: Any = self.client.xreadgroup(
|
|
206
|
+
self.group, self.consumer, {self.stream.name: ">"},
|
|
207
|
+
count=self.count, block=self.block_ms,
|
|
208
|
+
)
|
|
209
|
+
return [self._message(i, f, 1) for _, entries in result or [] for i, f in entries]
|
|
210
|
+
|
|
211
|
+
def _required_idle_ms(self, times_delivered: int) -> float:
|
|
212
|
+
wait = self.claim_idle_ms * (self.backoff ** max(times_delivered - 1, 0))
|
|
213
|
+
return min(wait, self.max_backoff_ms)
|
|
214
|
+
|
|
215
|
+
def _exhausted(self, deliveries: int) -> bool:
|
|
216
|
+
return self.max_retries is not None and deliveries > self.max_retries
|
|
217
|
+
|
|
218
|
+
def _claim(self) -> list[Message]:
|
|
219
|
+
"""Take over pending messages idle long enough, honouring the retry backoff."""
|
|
220
|
+
pending: Any = self.client.xpending_range(
|
|
221
|
+
self.stream.name, self.group, "-", "+", self.count, idle=self.claim_idle_ms
|
|
222
|
+
)
|
|
223
|
+
due = {
|
|
224
|
+
_text(p["message_id"]): p["times_delivered"]
|
|
225
|
+
for p in pending
|
|
226
|
+
if p["time_since_delivered"] >= self._required_idle_ms(p["times_delivered"])
|
|
227
|
+
}
|
|
228
|
+
if not due:
|
|
229
|
+
return []
|
|
230
|
+
claimed: Any = self.client.xclaim(
|
|
231
|
+
self.stream.name, self.group, self.consumer, self.claim_idle_ms, list(due)
|
|
232
|
+
)
|
|
233
|
+
messages = []
|
|
234
|
+
for id, raw in claimed:
|
|
235
|
+
if raw is None: # entry was trimmed from the stream while pending
|
|
236
|
+
self.client.xack(self.stream.name, self.group, id)
|
|
237
|
+
continue
|
|
238
|
+
attempts = due[_text(id)]
|
|
239
|
+
if self._exhausted(attempts):
|
|
240
|
+
# not run again, so the delivery count is the attempts it already had
|
|
241
|
+
self._dead_letter(self._message(id, raw, attempts), "retries exhausted")
|
|
242
|
+
continue
|
|
243
|
+
msg = self._message(id, raw, attempts + 1)
|
|
244
|
+
messages.append(msg)
|
|
245
|
+
return messages
|
|
246
|
+
|
|
247
|
+
# processing
|
|
248
|
+
|
|
249
|
+
def _invoke(self, msg: Message) -> Any:
|
|
250
|
+
kwargs: dict[str, Any] = {}
|
|
251
|
+
for name, kind, model in self._plan:
|
|
252
|
+
if kind == "message":
|
|
253
|
+
kwargs[name] = msg
|
|
254
|
+
elif kind == "model":
|
|
255
|
+
try:
|
|
256
|
+
kwargs[name] = model.model_validate(msg.body)
|
|
257
|
+
except DecodeError:
|
|
258
|
+
raise
|
|
259
|
+
except Exception as e:
|
|
260
|
+
raise DecodeError(f"body failed validation: {e}") from e
|
|
261
|
+
else:
|
|
262
|
+
kwargs[name] = msg.body
|
|
263
|
+
result = self.handler(**kwargs)
|
|
264
|
+
publishers = getattr(self.handler, "_rillkit_publishers", ())
|
|
265
|
+
if result is not None:
|
|
266
|
+
for publisher in publishers:
|
|
267
|
+
publisher.publish(result)
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
def _chain(self) -> Callable[[Message], Any]:
|
|
271
|
+
call = self._invoke
|
|
272
|
+
for mw in reversed(self.middlewares):
|
|
273
|
+
call = partial(mw.consume_scope, call)
|
|
274
|
+
return call
|
|
275
|
+
|
|
276
|
+
def _process(self, msg: Message) -> None:
|
|
277
|
+
policy = self.ack_policy
|
|
278
|
+
exc: Optional[BaseException] = None
|
|
279
|
+
if policy is AckPolicy.ACK_FIRST:
|
|
280
|
+
msg.ack()
|
|
281
|
+
try:
|
|
282
|
+
for mw in self.middlewares:
|
|
283
|
+
mw.on_receive(msg)
|
|
284
|
+
self._chain()(msg)
|
|
285
|
+
except AckMessage:
|
|
286
|
+
msg.ack()
|
|
287
|
+
except NackMessage as e:
|
|
288
|
+
msg.nack()
|
|
289
|
+
exc = e
|
|
290
|
+
except RejectMessage as e:
|
|
291
|
+
msg.reject(str(e) or "rejected")
|
|
292
|
+
exc = e
|
|
293
|
+
except DecodeError as e:
|
|
294
|
+
log.error("message %s cannot be decoded, sending to DLQ: %s", msg.id, e)
|
|
295
|
+
exc = e
|
|
296
|
+
self._dead_letter(msg, str(e))
|
|
297
|
+
except Exception as e:
|
|
298
|
+
log.error("handler failed for message %s (delivery %s): %s", msg.id, msg.deliveries, e)
|
|
299
|
+
exc = e
|
|
300
|
+
msg.error = e
|
|
301
|
+
if msg.rejected:
|
|
302
|
+
pass # the handler already asked to skip retries
|
|
303
|
+
elif policy is AckPolicy.ACK_ALWAYS:
|
|
304
|
+
msg.ack()
|
|
305
|
+
elif policy in (AckPolicy.NACK_ON_ERROR, AckPolicy.MANUAL):
|
|
306
|
+
msg.nack()
|
|
307
|
+
else:
|
|
308
|
+
auto_ack = policy in (AckPolicy.NACK_ON_ERROR, AckPolicy.ACK_ALWAYS)
|
|
309
|
+
if auto_ack and not msg.nacked and not msg.rejected:
|
|
310
|
+
msg.ack()
|
|
311
|
+
|
|
312
|
+
if msg.rejected:
|
|
313
|
+
reason = msg.reject_reason or (str(exc) or type(exc).__name__ if exc else "rejected")
|
|
314
|
+
self._dead_letter(msg, reason)
|
|
315
|
+
elif msg.nacked and not msg.acked and self._exhausted(msg.deliveries):
|
|
316
|
+
self._dead_letter(msg, (str(exc) or type(exc).__name__) if exc else "nacked")
|
|
317
|
+
self._after(msg, exc)
|
|
318
|
+
self._beat()
|
|
319
|
+
|
|
320
|
+
def _after(self, msg: Message, exc: Optional[BaseException]) -> None:
|
|
321
|
+
for mw in self.middlewares:
|
|
322
|
+
try:
|
|
323
|
+
mw.after_processed(msg, exc)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
log.error("after_processed failed in %s: %s", type(mw).__name__, e)
|
|
326
|
+
|
|
327
|
+
def _dead_letter(self, msg: Message, error: str) -> None:
|
|
328
|
+
"""Copy the entry to the DLQ stream with context, then ack it so it stops being retried."""
|
|
329
|
+
log.warning("moving message %s to %s: %s", msg.id, self.stream.dlq_name, error)
|
|
330
|
+
entry: dict[Any, Any] = dict(msg.raw)
|
|
331
|
+
entry.update({
|
|
332
|
+
"x-original-id": msg.id,
|
|
333
|
+
"x-stream": self.stream.name,
|
|
334
|
+
"x-group": self.group,
|
|
335
|
+
"x-error": error[:1000],
|
|
336
|
+
"x-deliveries": str(msg.deliveries),
|
|
337
|
+
})
|
|
338
|
+
pipe = self.client.pipeline()
|
|
339
|
+
pipe.xadd(self.stream.dlq_name, entry)
|
|
340
|
+
pipe.xack(self.stream.name, self.group, msg.id)
|
|
341
|
+
pipe.execute()
|
|
342
|
+
msg.acked = True
|
|
343
|
+
msg.nacked = False
|
rillkit/exceptions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exceptions raised by rillkit, and control-flow exceptions handlers can raise."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RillkitError(Exception):
|
|
5
|
+
"""Base class for all rillkit errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PublishError(RillkitError):
|
|
9
|
+
"""The message could not be written to the stream."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DecodeError(RillkitError):
|
|
13
|
+
"""The body could not be decoded or validated. Never retried, goes straight to the DLQ."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AckMessage(RillkitError):
|
|
17
|
+
"""Raise from a handler to stop processing and acknowledge the message."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RejectMessage(RillkitError):
|
|
21
|
+
"""Raise from a handler to stop processing and send the message to the DLQ without retrying.
|
|
22
|
+
|
|
23
|
+
The text of the exception is stored as the DLQ ``x-error``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NackMessage(RillkitError):
|
|
28
|
+
"""Raise from a handler to stop processing and nack the message so it is retried."""
|
rillkit/message.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
2
|
+
|
|
3
|
+
from .exceptions import DecodeError
|
|
4
|
+
from .stream import Stream
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import redis
|
|
8
|
+
|
|
9
|
+
_MISSING = object()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Message:
|
|
13
|
+
"""One stream entry as seen by a handler or middleware.
|
|
14
|
+
|
|
15
|
+
``body`` is the decoded payload (a plain dict for JSON objects). ``deliveries`` is how many
|
|
16
|
+
times Redis has delivered this entry to a consumer in the group, counting the current one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
id: str,
|
|
22
|
+
stream: Stream,
|
|
23
|
+
raw: dict[str, str],
|
|
24
|
+
deliveries: int,
|
|
25
|
+
consumer: str,
|
|
26
|
+
client: "redis.Redis",
|
|
27
|
+
) -> None:
|
|
28
|
+
self.id = id
|
|
29
|
+
self.stream = stream
|
|
30
|
+
self.raw = raw
|
|
31
|
+
self.deliveries = deliveries
|
|
32
|
+
self.consumer = consumer
|
|
33
|
+
self.acked = False
|
|
34
|
+
self.nacked = False
|
|
35
|
+
self.rejected = False
|
|
36
|
+
self.reject_reason: Optional[str] = None
|
|
37
|
+
self._client = client
|
|
38
|
+
self._body: Any = _MISSING
|
|
39
|
+
self.error: Optional[BaseException] = None
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def body(self) -> Any:
|
|
43
|
+
if self._body is _MISSING:
|
|
44
|
+
if "data" not in self.raw:
|
|
45
|
+
raise DecodeError("entry has no 'data' field")
|
|
46
|
+
self._body = self.stream.codec.decode(self.raw["data"])
|
|
47
|
+
return self._body
|
|
48
|
+
|
|
49
|
+
def ack(self) -> None:
|
|
50
|
+
"""Acknowledge the message. It is removed from the pending list and never redelivered."""
|
|
51
|
+
if self.acked:
|
|
52
|
+
return
|
|
53
|
+
assert self.stream.group is not None # only consumed streams have messages
|
|
54
|
+
self._client.xack(self.stream.name, self.stream.group, self.id)
|
|
55
|
+
self.acked = True
|
|
56
|
+
self.nacked = False
|
|
57
|
+
|
|
58
|
+
def nack(self) -> None:
|
|
59
|
+
"""Leave the message pending so it is retried (or sent to the DLQ once retries run out)."""
|
|
60
|
+
if not self.acked:
|
|
61
|
+
self.nacked = True
|
|
62
|
+
|
|
63
|
+
def reject(self, reason: Optional[str] = None) -> None:
|
|
64
|
+
"""Skip the remaining retries: the message goes to the DLQ as soon as the handler finishes.
|
|
65
|
+
|
|
66
|
+
Use it for messages that can never succeed (bad input, unknown customer). ``reason`` is
|
|
67
|
+
stored as ``x-error`` in the DLQ entry. Works under every ``AckPolicy``.
|
|
68
|
+
"""
|
|
69
|
+
self.rejected = True
|
|
70
|
+
self.reject_reason = reason
|
|
71
|
+
self.nacked = False
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return f"Message(id={self.id!r}, stream={self.stream.name!r}, deliveries={self.deliveries})"
|
rillkit/middleware.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
3
|
+
|
|
4
|
+
from .exceptions import AckMessage
|
|
5
|
+
from .message import Message
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .broker import Broker
|
|
9
|
+
from .stream import Stream
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PublishCommand:
|
|
14
|
+
"""What ``publish_scope`` receives. Mutate it to change what gets published."""
|
|
15
|
+
|
|
16
|
+
stream: "Stream"
|
|
17
|
+
body: Any
|
|
18
|
+
maxlen: Optional[int] = None
|
|
19
|
+
approximate: bool = True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseMiddleware:
|
|
23
|
+
"""Subclass and override any hook. Instances are shared by all messages, so keep per-message
|
|
24
|
+
state on the ``Message``, not on ``self``. ``self.broker`` is set when the middleware is added.
|
|
25
|
+
|
|
26
|
+
Order: ``on_receive`` -> ``consume_scope`` (wraps decode + handler) -> ``after_processed``.
|
|
27
|
+
The first middleware in the list is the outermost.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
broker: "Broker"
|
|
31
|
+
|
|
32
|
+
def on_receive(self, msg: Message) -> None:
|
|
33
|
+
"""Called first for every delivered message, before decoding."""
|
|
34
|
+
|
|
35
|
+
def consume_scope(self, call_next: Callable[[Message], Any], msg: Message) -> Any:
|
|
36
|
+
"""Wrap the handler call. Raise ``AckMessage``/``NackMessage`` to short-circuit."""
|
|
37
|
+
return call_next(msg)
|
|
38
|
+
|
|
39
|
+
def publish_scope(self, call_next: Callable[[PublishCommand], str], cmd: PublishCommand) -> str:
|
|
40
|
+
"""Wrap every publish, including handler return values."""
|
|
41
|
+
return call_next(cmd)
|
|
42
|
+
|
|
43
|
+
def after_processed(self, msg: Message, exc: Optional[BaseException]) -> None:
|
|
44
|
+
"""Always called last. ``exc`` is the handler error, if any."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Idempotent(BaseMiddleware):
|
|
48
|
+
"""Skip messages whose key was already processed successfully.
|
|
49
|
+
|
|
50
|
+
Redis delivers at least once, so a crash after the handler finished but before the ack means a
|
|
51
|
+
redelivery. The key is stored with ``ttl`` seconds once the handler returns without raising.
|
|
52
|
+
|
|
53
|
+
broker = Broker(middlewares=[Idempotent(key=lambda m: m.body["request_id"])])
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
key: Callable[[Message], str],
|
|
59
|
+
ttl: int = 7 * 24 * 3600,
|
|
60
|
+
prefix: str = "rillkit:done:",
|
|
61
|
+
):
|
|
62
|
+
self.key = key
|
|
63
|
+
self.ttl = ttl
|
|
64
|
+
self.prefix = prefix
|
|
65
|
+
|
|
66
|
+
def _redis_key(self, msg: Message) -> str:
|
|
67
|
+
return f"{self.prefix}{msg.stream.name}:{self.key(msg)}"
|
|
68
|
+
|
|
69
|
+
def consume_scope(self, call_next: Callable[[Message], Any], msg: Message) -> Any:
|
|
70
|
+
redis_key = self._redis_key(msg)
|
|
71
|
+
if self.broker.client.exists(redis_key):
|
|
72
|
+
raise AckMessage()
|
|
73
|
+
result = call_next(msg)
|
|
74
|
+
self.broker.client.set(redis_key, msg.id, ex=self.ttl)
|
|
75
|
+
return result
|
rillkit/py.typed
ADDED
|
File without changes
|
rillkit/settings.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import redis
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Settings:
|
|
10
|
+
"""Connection settings.
|
|
11
|
+
|
|
12
|
+
``Settings.from_env()`` reads REDIS_URL, or REDIS_HOST/PORT/DB/PASSWORD/SSL.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
url: Optional[str] = None
|
|
16
|
+
host: str = "localhost"
|
|
17
|
+
port: int = 6379
|
|
18
|
+
db: int = 0
|
|
19
|
+
password: Optional[str] = None
|
|
20
|
+
ssl: bool = False
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_env(cls, prefix: str = "REDIS_") -> "Settings":
|
|
24
|
+
env = os.environ
|
|
25
|
+
return cls(
|
|
26
|
+
url=env.get(f"{prefix}URL"),
|
|
27
|
+
host=env.get(f"{prefix}HOST", "localhost"),
|
|
28
|
+
port=int(env.get(f"{prefix}PORT", 6379)),
|
|
29
|
+
db=int(env.get(f"{prefix}DB", 0)),
|
|
30
|
+
password=env.get(f"{prefix}PASSWORD"),
|
|
31
|
+
ssl=env.get(f"{prefix}SSL", "").lower() in ("1", "true", "yes"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def client(self) -> "redis.Redis":
|
|
35
|
+
if self.url:
|
|
36
|
+
return redis.Redis.from_url(self.url)
|
|
37
|
+
return redis.Redis(
|
|
38
|
+
host=self.host, port=self.port, db=self.db, password=self.password, ssl=self.ssl
|
|
39
|
+
)
|
rillkit/stream.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
|
|
4
|
+
from .codec import Codec, JsonCodec
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Stream:
|
|
9
|
+
"""A stream name plus everything that should be agreed on by both sides.
|
|
10
|
+
|
|
11
|
+
``group`` is only needed by consumers. ``dlq`` defaults to ``<name>-dlq``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
group: Optional[str] = None
|
|
16
|
+
dlq: Optional[str] = None
|
|
17
|
+
maxlen: Optional[int] = None
|
|
18
|
+
codec: Codec = field(default_factory=JsonCodec, compare=False, repr=False)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def dlq_name(self) -> str:
|
|
22
|
+
return self.dlq or f"{self.name}-dlq"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
StreamLike = Union[str, Stream]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def as_stream(stream: StreamLike) -> Stream:
|
|
29
|
+
return stream if isinstance(stream, Stream) else Stream(stream)
|
|
@@ -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.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
rillkit/__init__.py,sha256=Nd-o6hG0u4yzPs-iZU5xA2dwfHtQhNkGmqKjRH1_Jf4,734
|
|
2
|
+
rillkit/ack.py,sha256=JKm-E0ggLpY_jsK2eVFtruWu7gzBq1XNYrxJIZtuvgE,722
|
|
3
|
+
rillkit/broker.py,sha256=z-d_vvLligmFpTTteFsiCAqIkVhAwARNwwkuluuGwzs,9936
|
|
4
|
+
rillkit/codec.py,sha256=D5PS2fF_nBkZXGgYcGfRJOzOQM7K3c4DvBgXaJnm3LI,710
|
|
5
|
+
rillkit/consumer.py,sha256=E1KH6bEWIqTe1D7eVvEFu0IbQ2qntADAcGOTyy0L4Uw,12511
|
|
6
|
+
rillkit/exceptions.py,sha256=bjxioj7x438t_34GokSGlf0fC2V9hsXG19bA8TV5JQI,830
|
|
7
|
+
rillkit/message.py,sha256=nwjBIBa3KlC1vOIszAWt7SfFdMU7FW2lDE8sBKgn2TI,2451
|
|
8
|
+
rillkit/middleware.py,sha256=UPhoG9vaRFeh_CYYRROnl1jJiOPkzaeYLcUR55Sc7p0,2606
|
|
9
|
+
rillkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
rillkit/settings.py,sha256=jFXD1e4MgSiArvWl3Eu_g0WWR1b2qv9ebXQ_u4nuVdk,1088
|
|
11
|
+
rillkit/stream.py,sha256=YEf7P4w6VMDHibRGVdq4yBTnYcRcaYkijWnvBMcQBGg,748
|
|
12
|
+
rillkit-0.1.0.dist-info/METADATA,sha256=ggADWT_yFXImp-E4kTVmuHu2Qq7vHa99CIWvfVNC0cI,3255
|
|
13
|
+
rillkit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
14
|
+
rillkit-0.1.0.dist-info/licenses/LICENSE,sha256=oWHoi3y0pjlKKUoN9T8Pfswaojv_Mg98vX6U1uz770k,1069
|
|
15
|
+
rillkit-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|