rillkit 0.1.0__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {rillkit-0.1.0 → rillkit-0.1.1}/PKG-INFO +1 -1
- {rillkit-0.1.0 → rillkit-0.1.1}/pyproject.toml +1 -1
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/__init__.py +1 -1
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/consumer.py +26 -1
- rillkit-0.1.1/src/rillkit/settings.py +57 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/helpers.py +31 -0
- rillkit-0.1.1/tests/test_blocking_timeout.py +56 -0
- rillkit-0.1.0/src/rillkit/settings.py +0 -39
- {rillkit-0.1.0 → rillkit-0.1.1}/.gitignore +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/LICENSE +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/README.md +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/ack.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/broker.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/codec.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/exceptions.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/message.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/middleware.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/py.typed +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/stream.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/conftest.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_consume.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_lifecycle.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_middleware.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_publish.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_publisher_decorator.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_retries.py +0 -0
- {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: rillkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Simple publish and consume for Redis Streams: consumer groups, retries, DLQ, ack policies and middleware.
|
|
5
5
|
Project-URL: Homepage, https://nimzymaina.github.io/rillkit/
|
|
6
6
|
Project-URL: Documentation, https://nimzymaina.github.io/rillkit/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rillkit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Simple publish and consume for Redis Streams: consumer groups, retries, DLQ, ack policies and middleware."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -16,7 +16,7 @@ from .middleware import BaseMiddleware, Idempotent, PublishCommand
|
|
|
16
16
|
from .settings import Settings
|
|
17
17
|
from .stream import Stream
|
|
18
18
|
|
|
19
|
-
__version__ = "0.1.
|
|
19
|
+
__version__ = "0.1.1"
|
|
20
20
|
|
|
21
21
|
__all__ = [
|
|
22
22
|
"AckMessage", "AckPolicy", "BaseMiddleware", "Broker", "Codec", "DecodeError", "Idempotent",
|
|
@@ -39,6 +39,21 @@ def _fields(raw: dict[Any, Any]) -> dict[str, str]:
|
|
|
39
39
|
return {_text(k): _text(v) for k, v in raw.items()}
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _socket_timeout(client: Any) -> Optional[float]:
|
|
43
|
+
"""The read timeout a connection from this client will use, or None for no timeout."""
|
|
44
|
+
try:
|
|
45
|
+
conn = client.connection_pool.make_connection()
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return getattr(conn, "socket_timeout", None) or getattr(conn, "_socket_timeout", None)
|
|
50
|
+
finally:
|
|
51
|
+
try:
|
|
52
|
+
conn.disconnect()
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
42
57
|
def _is_model(annotation: Any) -> bool:
|
|
43
58
|
return inspect.isclass(annotation) and hasattr(annotation, "model_validate")
|
|
44
59
|
|
|
@@ -102,6 +117,7 @@ class Subscriber:
|
|
|
102
117
|
self.backoff = backoff
|
|
103
118
|
self.max_backoff_ms = max_backoff_ms
|
|
104
119
|
self.block_ms = block_ms
|
|
120
|
+
self._block_ms = block_ms # what is actually used, see setup()
|
|
105
121
|
self.count = count
|
|
106
122
|
self.own_middlewares = list(middlewares)
|
|
107
123
|
self.consumer = consumer or f"{stream.group}-{socket.gethostname()}-{os.getpid()}"
|
|
@@ -128,6 +144,15 @@ class Subscriber:
|
|
|
128
144
|
# lifecycle
|
|
129
145
|
|
|
130
146
|
def setup(self) -> None:
|
|
147
|
+
# A blocking read that outlasts the client's socket timeout fails and is retried by
|
|
148
|
+
# redis-py, so keep the block time a second under the timeout.
|
|
149
|
+
timeout = _socket_timeout(self.client)
|
|
150
|
+
if timeout is not None and self.block_ms / 1000 > timeout - 1:
|
|
151
|
+
self._block_ms = max(100, int((timeout - 1) * 1000))
|
|
152
|
+
log.warning(
|
|
153
|
+
"client socket_timeout is %.1f s, shorter than block_ms=%d; using %d ms",
|
|
154
|
+
timeout, self.block_ms, self._block_ms,
|
|
155
|
+
)
|
|
131
156
|
try:
|
|
132
157
|
self.client.xgroup_create(
|
|
133
158
|
self.stream.name, self.group, id=self.group_start_id, mkstream=True
|
|
@@ -204,7 +229,7 @@ class Subscriber:
|
|
|
204
229
|
def _read(self) -> list[Message]:
|
|
205
230
|
result: Any = self.client.xreadgroup(
|
|
206
231
|
self.group, self.consumer, {self.stream.name: ">"},
|
|
207
|
-
count=self.count, block=self.
|
|
232
|
+
count=self.count, block=self._block_ms,
|
|
208
233
|
)
|
|
209
234
|
return [self._message(i, f, 1) for _, entries in result or [] for i, f in entries]
|
|
210
235
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import redis
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _seconds(value: Optional[str], default: Optional[float]) -> Optional[float]:
|
|
9
|
+
if value is None or value == "":
|
|
10
|
+
return default
|
|
11
|
+
return None if value.strip().lower() in ("none", "off", "0") else float(value)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Settings:
|
|
16
|
+
"""Connection settings.
|
|
17
|
+
|
|
18
|
+
``Settings.from_env()`` reads REDIS_URL, or REDIS_HOST/PORT/DB/PASSWORD/SSL, plus
|
|
19
|
+
REDIS_SOCKET_TIMEOUT (seconds, or ``none``).
|
|
20
|
+
|
|
21
|
+
``socket_timeout`` is set explicitly because consumers block in ``XREADGROUP`` for up to
|
|
22
|
+
``block_ms`` and the client must wait longer than that. redis-py 8 changed its own default to
|
|
23
|
+
5 s, which is shorter than rillkit's default block time.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
url: Optional[str] = None
|
|
27
|
+
host: str = "localhost"
|
|
28
|
+
port: int = 6379
|
|
29
|
+
db: int = 0
|
|
30
|
+
password: Optional[str] = None
|
|
31
|
+
ssl: bool = False
|
|
32
|
+
socket_timeout: Optional[float] = 15.0
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_env(cls, prefix: str = "REDIS_") -> "Settings":
|
|
36
|
+
env = os.environ
|
|
37
|
+
return cls(
|
|
38
|
+
url=env.get(f"{prefix}URL"),
|
|
39
|
+
host=env.get(f"{prefix}HOST", "localhost"),
|
|
40
|
+
port=int(env.get(f"{prefix}PORT", 6379)),
|
|
41
|
+
db=int(env.get(f"{prefix}DB", 0)),
|
|
42
|
+
password=env.get(f"{prefix}PASSWORD"),
|
|
43
|
+
ssl=env.get(f"{prefix}SSL", "").lower() in ("1", "true", "yes"),
|
|
44
|
+
socket_timeout=_seconds(env.get(f"{prefix}SOCKET_TIMEOUT"), 15.0),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def client(self) -> "redis.Redis":
|
|
48
|
+
if self.url:
|
|
49
|
+
return redis.Redis.from_url(self.url, socket_timeout=self.socket_timeout)
|
|
50
|
+
return redis.Redis(
|
|
51
|
+
host=self.host,
|
|
52
|
+
port=self.port,
|
|
53
|
+
db=self.db,
|
|
54
|
+
password=self.password,
|
|
55
|
+
ssl=self.ssl,
|
|
56
|
+
socket_timeout=self.socket_timeout,
|
|
57
|
+
)
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import threading
|
|
1
3
|
import time
|
|
4
|
+
from collections.abc import Iterator
|
|
2
5
|
from typing import Any, Callable
|
|
3
6
|
|
|
4
7
|
import redis
|
|
@@ -21,6 +24,34 @@ def fast(**overrides: Any) -> dict[str, Any]:
|
|
|
21
24
|
return opts
|
|
22
25
|
|
|
23
26
|
|
|
27
|
+
@contextlib.contextmanager
|
|
28
|
+
def keep_server_awake(url: str, interval: float = 0.05) -> Iterator[None]:
|
|
29
|
+
"""Send a cheap PING every ``interval`` seconds from another client.
|
|
30
|
+
|
|
31
|
+
Harmless against a real Redis. Some sandboxed local servers only fire blocking-command
|
|
32
|
+
timeouts when other traffic wakes their event loop, which makes idle-consumer tests hang.
|
|
33
|
+
"""
|
|
34
|
+
stop = threading.Event()
|
|
35
|
+
client = redis.Redis.from_url(url)
|
|
36
|
+
|
|
37
|
+
def ping() -> None:
|
|
38
|
+
while not stop.is_set():
|
|
39
|
+
try:
|
|
40
|
+
client.ping()
|
|
41
|
+
except redis.RedisError:
|
|
42
|
+
pass
|
|
43
|
+
stop.wait(interval)
|
|
44
|
+
|
|
45
|
+
thread = threading.Thread(target=ping, daemon=True)
|
|
46
|
+
thread.start()
|
|
47
|
+
try:
|
|
48
|
+
yield
|
|
49
|
+
finally:
|
|
50
|
+
stop.set()
|
|
51
|
+
thread.join(2)
|
|
52
|
+
client.close()
|
|
53
|
+
|
|
54
|
+
|
|
24
55
|
def pending(r: "redis.Redis", stream: str, group: str = "g") -> int:
|
|
25
56
|
return int(r.xpending(stream, group)["pending"])
|
|
26
57
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The default blocking read (5 s) must not collide with the Redis client's socket timeout.
|
|
2
|
+
|
|
3
|
+
redis-py 8 changed the default socket_timeout from "none" to 5 s, which equals rillkit's default
|
|
4
|
+
block_ms, so an idle consumer's read timed out right as the server would have replied.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import redis
|
|
11
|
+
|
|
12
|
+
from helpers import keep_server_awake, wait_until
|
|
13
|
+
from rillkit import Broker, Settings, Stream
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _run_idle_then_publish(
|
|
17
|
+
broker: Broker, stream: Stream, caplog, idle_seconds: float, url: str
|
|
18
|
+
) -> list:
|
|
19
|
+
got: list = []
|
|
20
|
+
|
|
21
|
+
@broker.subscribe(stream) # library defaults: block_ms=5000, heartbeat on
|
|
22
|
+
def handle(body: dict):
|
|
23
|
+
got.append(body)
|
|
24
|
+
|
|
25
|
+
with keep_server_awake(url), caplog.at_level(logging.ERROR, logger="rillkit"):
|
|
26
|
+
broker.start()
|
|
27
|
+
time.sleep(idle_seconds)
|
|
28
|
+
broker.publish(stream, {"n": 1})
|
|
29
|
+
assert wait_until(lambda: got, timeout=8)
|
|
30
|
+
broker.stop()
|
|
31
|
+
errors = [r.getMessage() for r in caplog.records if r.levelno >= logging.ERROR]
|
|
32
|
+
assert errors == []
|
|
33
|
+
return got
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_default_block_with_short_client_timeout(redis_url, name, caplog):
|
|
37
|
+
"""A caller-supplied client with a 2 s socket timeout and the default 5 s block."""
|
|
38
|
+
client = redis.Redis.from_url(redis_url, socket_timeout=2)
|
|
39
|
+
broker = Broker(client=client)
|
|
40
|
+
stream = Stream(name, group="g")
|
|
41
|
+
_run_idle_then_publish(broker, stream, caplog, idle_seconds=4, url=redis_url)
|
|
42
|
+
assert broker.subscribers[0]._block_ms < 2000 # clamped below the socket timeout
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_default_settings_survive_an_idle_period(redis_url, name, caplog):
|
|
46
|
+
"""The client rillkit builds itself works with the defaults across more than one idle pass."""
|
|
47
|
+
broker = Broker(settings=Settings(url=redis_url))
|
|
48
|
+
stream = Stream(name, group="g")
|
|
49
|
+
_run_idle_then_publish(broker, stream, caplog, idle_seconds=6, url=redis_url)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_settings_socket_timeout_comes_from_env(monkeypatch):
|
|
53
|
+
monkeypatch.setenv("REDIS_SOCKET_TIMEOUT", "30")
|
|
54
|
+
assert Settings.from_env().socket_timeout == 30.0
|
|
55
|
+
monkeypatch.setenv("REDIS_SOCKET_TIMEOUT", "none")
|
|
56
|
+
assert Settings.from_env().socket_timeout is None
|
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|