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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.