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.
Files changed (27) hide show
  1. {rillkit-0.1.0 → rillkit-0.1.1}/PKG-INFO +1 -1
  2. {rillkit-0.1.0 → rillkit-0.1.1}/pyproject.toml +1 -1
  3. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/__init__.py +1 -1
  4. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/consumer.py +26 -1
  5. rillkit-0.1.1/src/rillkit/settings.py +57 -0
  6. {rillkit-0.1.0 → rillkit-0.1.1}/tests/helpers.py +31 -0
  7. rillkit-0.1.1/tests/test_blocking_timeout.py +56 -0
  8. rillkit-0.1.0/src/rillkit/settings.py +0 -39
  9. {rillkit-0.1.0 → rillkit-0.1.1}/.gitignore +0 -0
  10. {rillkit-0.1.0 → rillkit-0.1.1}/LICENSE +0 -0
  11. {rillkit-0.1.0 → rillkit-0.1.1}/README.md +0 -0
  12. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/ack.py +0 -0
  13. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/broker.py +0 -0
  14. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/codec.py +0 -0
  15. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/exceptions.py +0 -0
  16. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/message.py +0 -0
  17. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/middleware.py +0 -0
  18. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/py.typed +0 -0
  19. {rillkit-0.1.0 → rillkit-0.1.1}/src/rillkit/stream.py +0 -0
  20. {rillkit-0.1.0 → rillkit-0.1.1}/tests/conftest.py +0 -0
  21. {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_consume.py +0 -0
  22. {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_lifecycle.py +0 -0
  23. {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_middleware.py +0 -0
  24. {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_publish.py +0 -0
  25. {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_publisher_decorator.py +0 -0
  26. {rillkit-0.1.0 → rillkit-0.1.1}/tests/test_retries.py +0 -0
  27. {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.0
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.0"
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.0"
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.block_ms,
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