rillkit 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {rillkit-0.2.0 → rillkit-0.3.0}/PKG-INFO +12 -10
  2. {rillkit-0.2.0 → rillkit-0.3.0}/README.md +7 -5
  3. {rillkit-0.2.0 → rillkit-0.3.0}/pyproject.toml +5 -5
  4. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/__init__.py +1 -1
  5. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/broker.py +9 -7
  6. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/consumer.py +16 -2
  7. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/keys.py +49 -3
  8. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/middleware.py +10 -3
  9. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/settings.py +33 -14
  10. {rillkit-0.2.0 → rillkit-0.3.0}/tests/helpers.py +10 -0
  11. rillkit-0.3.0/tests/test_acl.py +115 -0
  12. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_cluster.py +2 -2
  13. rillkit-0.3.0/tests/test_keys.py +135 -0
  14. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_lifecycle.py +39 -1
  15. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_middleware.py +60 -4
  16. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_settings.py +61 -1
  17. rillkit-0.2.0/tests/test_keys.py +0 -60
  18. {rillkit-0.2.0 → rillkit-0.3.0}/.gitignore +0 -0
  19. {rillkit-0.2.0 → rillkit-0.3.0}/LICENSE +0 -0
  20. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/ack.py +0 -0
  21. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/clients.py +0 -0
  22. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/codec.py +0 -0
  23. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/exceptions.py +0 -0
  24. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/message.py +0 -0
  25. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/py.typed +0 -0
  26. {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/stream.py +0 -0
  27. {rillkit-0.2.0 → rillkit-0.3.0}/tests/conftest.py +0 -0
  28. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_blocking_timeout.py +0 -0
  29. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_consume.py +0 -0
  30. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_publish.py +0 -0
  31. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_publisher_decorator.py +0 -0
  32. {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_retries.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: rillkit
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
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
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
9
  Author: Nimrod Maina
10
10
  License: MIT
11
11
  License-File: LICENSE
@@ -35,7 +35,7 @@ Simple publish and consume for Redis and Valkey streams, standalone or cluster.
35
35
  retries with backoff, a dead-letter queue, ack policies, middleware and lifecycle hooks, without the
36
36
  boilerplate.
37
37
 
38
- **Docs:** https://nimzymaina.github.io/rillkit/
38
+ **Docs:** https://nimzymaina.github.io/RillKit/
39
39
 
40
40
  ```bash
41
41
  pip install rillkit # plain dict bodies
@@ -47,7 +47,7 @@ pip install "rillkit[valkey]" # optional valkey-py client
47
47
  from rillkit import Broker, Stream
48
48
 
49
49
  orders = Stream("orders", group="workers")
50
- broker = Broker.from_env() # REDIS_URL, or REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_SSL
50
+ broker = Broker.from_env() # RILLKIT_URL, or RILLKIT_HOST/PORT/...; REDIS_* still work
51
51
 
52
52
 
53
53
  @broker.subscribe(orders, max_retries=3)
@@ -72,9 +72,11 @@ if __name__ == "__main__":
72
72
  - `on_startup`, `after_startup`, `on_shutdown`, `after_shutdown` hooks and graceful SIGTERM shutdown
73
73
  - Worker heartbeats: `broker.active_workers(stream)` includes idle workers
74
74
  - Stack `@broker.publisher(...)` under `@broker.subscribe(...)` to publish a handler's return value
75
- - The wire format is a single `data` field holding JSON, so plain redis-py producers interoperate
76
- - Redis or Valkey (`RILLKIT_BACKEND=valkey`), standalone or Cluster (`RILLKIT_MODE=cluster`), with
77
- the stream and its DLQ in one hash slot so the move to the DLQ stays a transaction
75
+ - The wire format is a single `data` field holding JSON, so plain redis-py or valkey-py producers interoperate
76
+ - Redis or Valkey (`RILLKIT_BACKEND=valkey`), standalone or Cluster (`RILLKIT_MODE=cluster`)
77
+ - Key builders map stream names to keys: the original names on one server, hash-tagged keys on a
78
+ cluster so a stream and its DLQ share a slot and the move to the DLQ stays a transaction, plus
79
+ prefixes and tenant ids (`HashTagKeys(prefix="billing", tenant="t-42")`)
78
80
 
79
81
  ## Development
80
82
 
@@ -4,7 +4,7 @@ Simple publish and consume for Redis and Valkey streams, standalone or cluster.
4
4
  retries with backoff, a dead-letter queue, ack policies, middleware and lifecycle hooks, without the
5
5
  boilerplate.
6
6
 
7
- **Docs:** https://nimzymaina.github.io/rillkit/
7
+ **Docs:** https://nimzymaina.github.io/RillKit/
8
8
 
9
9
  ```bash
10
10
  pip install rillkit # plain dict bodies
@@ -16,7 +16,7 @@ pip install "rillkit[valkey]" # optional valkey-py client
16
16
  from rillkit import Broker, Stream
17
17
 
18
18
  orders = Stream("orders", group="workers")
19
- broker = Broker.from_env() # REDIS_URL, or REDIS_HOST / REDIS_PORT / REDIS_PASSWORD / REDIS_SSL
19
+ broker = Broker.from_env() # RILLKIT_URL, or RILLKIT_HOST/PORT/...; REDIS_* still work
20
20
 
21
21
 
22
22
  @broker.subscribe(orders, max_retries=3)
@@ -41,9 +41,11 @@ if __name__ == "__main__":
41
41
  - `on_startup`, `after_startup`, `on_shutdown`, `after_shutdown` hooks and graceful SIGTERM shutdown
42
42
  - Worker heartbeats: `broker.active_workers(stream)` includes idle workers
43
43
  - Stack `@broker.publisher(...)` under `@broker.subscribe(...)` to publish a handler's return value
44
- - The wire format is a single `data` field holding JSON, so plain redis-py producers interoperate
45
- - Redis or Valkey (`RILLKIT_BACKEND=valkey`), standalone or Cluster (`RILLKIT_MODE=cluster`), with
46
- the stream and its DLQ in one hash slot so the move to the DLQ stays a transaction
44
+ - The wire format is a single `data` field holding JSON, so plain redis-py or valkey-py producers interoperate
45
+ - Redis or Valkey (`RILLKIT_BACKEND=valkey`), standalone or Cluster (`RILLKIT_MODE=cluster`)
46
+ - Key builders map stream names to keys: the original names on one server, hash-tagged keys on a
47
+ cluster so a stream and its DLQ share a slot and the move to the DLQ stays a transaction, plus
48
+ prefixes and tenant ids (`HashTagKeys(prefix="billing", tenant="t-42")`)
47
49
 
48
50
  ## Development
49
51
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rillkit"
7
- version = "0.2.0"
7
+ version = "0.3.0"
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"
@@ -31,10 +31,10 @@ pydantic = ["pydantic>=2.0"]
31
31
  valkey = ["valkey>=6.1.1,<6.3"]
32
32
 
33
33
  [project.urls]
34
- Homepage = "https://nimzymaina.github.io/rillkit/"
35
- Documentation = "https://nimzymaina.github.io/rillkit/"
36
- Repository = "https://github.com/NimzyMaina/rillkit"
37
- Issues = "https://github.com/NimzyMaina/rillkit/issues"
34
+ Homepage = "https://nimzymaina.github.io/RillKit/"
35
+ Documentation = "https://nimzymaina.github.io/RillKit/"
36
+ Repository = "https://github.com/NimzyMaina/RillKit"
37
+ Issues = "https://github.com/NimzyMaina/RillKit/issues"
38
38
 
39
39
  [dependency-groups]
40
40
  dev = [
@@ -18,7 +18,7 @@ from .middleware import BaseMiddleware, Idempotent, PublishCommand
18
18
  from .settings import Settings
19
19
  from .stream import Stream
20
20
 
21
- __version__ = "0.2.0"
21
+ __version__ = "0.3.0"
22
22
 
23
23
  __all__ = [
24
24
  "AckMessage", "AckPolicy", "BaseMiddleware", "Broker", "Codec", "ConfigurationError",
@@ -2,6 +2,7 @@ import logging
2
2
  import signal
3
3
  import sys
4
4
  import threading
5
+ import time
5
6
  from collections.abc import Sequence
6
7
  from functools import partial
7
8
  from typing import Any, Callable, Optional
@@ -10,7 +11,7 @@ from .ack import AckPolicy
10
11
  from .clients import CLIENT_ERRORS, Client, is_cluster, run_together
11
12
  from .consumer import Subscriber, _text
12
13
  from .exceptions import PublishError
13
- from .keys import HashTagKeys, KeyBuilder, PlainKeys, glob_escape
14
+ from .keys import HashTagKeys, KeyBuilder, PlainKeys
14
15
  from .middleware import BaseMiddleware, PublishCommand
15
16
  from .settings import Settings
16
17
  from .stream import Stream, StreamLike, as_stream
@@ -265,13 +266,14 @@ class Broker:
265
266
  """Consumer names with a live heartbeat. Unlike XINFO CONSUMERS this includes idle workers
266
267
  that have not received a message yet.
267
268
 
268
- On a cluster every primary is scanned, and a key seen twice (during a slot migration) is
269
- counted once. If a node cannot be reached the client error is raised rather than
270
- returning a partial list."""
269
+ Reads the consumer registry (``broker.keys.consumers``), one sorted set per stream and
270
+ group, so it needs no ``SCAN`` and works for ACL users that may not list keys. Expiry is
271
+ judged by the workers' and caller's clocks, which should agree within the heartbeat TTL.
272
+ Workers older than 0.3, which write no registry entry, are not listed."""
271
273
  s = as_stream(stream)
272
- prefix = self.keys.heartbeat_prefix(s, str(group or s.group))
273
- found = {_text(k) for k in self.client.scan_iter(match=f"{glob_escape(prefix)}*")}
274
- return sorted(k[len(prefix):] for k in found)
274
+ key = self.keys.consumers(s, str(group or s.group))
275
+ members: Any = self.client.zrangebyscore(key, int(time.time() * 1000), "+inf")
276
+ return sorted(_text(m) for m in members)
275
277
 
276
278
  def replay_dlq(self, stream: StreamLike, count: int = 100) -> int:
277
279
  """Move up to ``count`` entries from the DLQ back onto the stream. Returns how many.
@@ -135,6 +135,11 @@ class Subscriber:
135
135
  def heartbeat_key(self) -> str:
136
136
  return self.broker.keys.heartbeat(self.stream, self.group, self.consumer)
137
137
 
138
+ @property
139
+ def registry_key(self) -> str:
140
+ """The sorted set of live consumers of this stream and group (``active_workers``)."""
141
+ return self.broker.keys.consumers(self.stream, self.group)
142
+
138
143
  # lifecycle
139
144
 
140
145
  def setup(self) -> None:
@@ -196,7 +201,10 @@ class Subscriber:
196
201
  finally:
197
202
  if self.heartbeat:
198
203
  try:
199
- self.client.delete(self.heartbeat_key)
204
+ pipe = self.client.pipeline(transaction=False)
205
+ pipe.delete(self.heartbeat_key)
206
+ pipe.zrem(self.registry_key, self.consumer)
207
+ pipe.execute()
200
208
  except Exception as e:
201
209
  log.error("could not remove heartbeat key: %s", e)
202
210
  log.info(
@@ -210,8 +218,14 @@ class Subscriber:
210
218
  def _beat(self) -> None:
211
219
  if not self.heartbeat:
212
220
  return
221
+ now = time.time()
213
222
  try:
214
- self.client.set(self.heartbeat_key, int(time.time()), ex=self.heartbeat_ttl)
223
+ pipe = self.client.pipeline(transaction=False)
224
+ pipe.set(self.heartbeat_key, int(now), ex=self.heartbeat_ttl)
225
+ # the registry: this consumer is alive until now + ttl; drop entries already expired
226
+ pipe.zadd(self.registry_key, {self.consumer: int((now + self.heartbeat_ttl) * 1000)})
227
+ pipe.zremrangebyscore(self.registry_key, "-inf", int(now * 1000))
228
+ pipe.execute()
215
229
  except Exception as e:
216
230
  log.error("heartbeat failed: %s", e)
217
231
 
@@ -6,6 +6,17 @@ and written. Every command rillkit sends goes through the broker's key builder.
6
6
  ``PlainKeys`` (the default outside a cluster) keeps the names rillkit has always used, so an
7
7
  existing deployment sees the same keys. ``HashTagKeys`` (the default on a cluster) puts a stream,
8
8
  its DLQ and its heartbeats in one hash slot so the move to the DLQ can stay a transaction.
9
+
10
+ Reserved for planned control-plane keys (worker state, directive dedup, node heartbeats, logs and
11
+ producer cordons), so that adding them never moves or collides with existing keys:
12
+
13
+ - ``PlainKeys``: names under ``rillkit:`` other than ``rillkit:workers:``, ``rillkit:done:`` and
14
+ ``rillkit:consumers:``,
15
+ namely ``rillkit:state:``, ``rillkit:dedup:``, ``rillkit:nodes:``, ``rillkit:ctl:``,
16
+ ``rillkit:logs:`` and ``rillkit:cordon:``.
17
+ - ``HashTagKeys``: the control tag ``{<tenant>:}`` (``{:}`` without a tenant) inside each
18
+ prefix, and the suffixes ``logs`` and ``cordon`` after a stream's tag. No stream can produce
19
+ the control tag: stream names are non-empty and their colons are percent-encoded.
9
20
  """
10
21
 
11
22
  from typing import Optional
@@ -14,6 +25,8 @@ from .exceptions import ConfigurationError
14
25
  from .stream import Stream
15
26
 
16
27
  HEARTBEAT_PREFIX = "rillkit:workers:"
28
+ IDEMPOTENCY_PREFIX = "rillkit:done:"
29
+ CONSUMERS_PREFIX = "rillkit:consumers:"
17
30
  _GLOB_SPECIAL = str.maketrans({c: f"\\{c}" for c in "*?[]\\"})
18
31
 
19
32
 
@@ -42,6 +55,19 @@ class KeyBuilder:
42
55
  def heartbeat(self, stream: Stream, group: str, consumer: str) -> str:
43
56
  return f"{self.heartbeat_prefix(stream, group)}{consumer}"
44
57
 
58
+ def idempotency(self, stream: Stream, key: str) -> str:
59
+ """Where ``Idempotent`` records that ``key`` was processed for ``stream``.
60
+
61
+ The default is the key rillkit has always used, so custom builders that do not override
62
+ it keep their existing markers."""
63
+ return f"{IDEMPOTENCY_PREFIX}{stream.name}:{key}"
64
+
65
+ def consumers(self, stream: Stream, group: str) -> str:
66
+ """The registry of live consumers of ``stream`` in ``group``: a sorted set of consumer
67
+ names scored by when their heartbeat expires (milliseconds). ``active_workers`` reads it,
68
+ so finding workers never needs ``SCAN``."""
69
+ return f"{CONSUMERS_PREFIX}{_escape_plain(stream.name)}:{group}"
70
+
45
71
 
46
72
  class PlainKeys(KeyBuilder):
47
73
  """The stream name is the key; the DLQ is ``dlq`` or ``<name>-dlq``. rillkit's original layout.
@@ -63,6 +89,11 @@ class PlainKeys(KeyBuilder):
63
89
  return "PlainKeys()"
64
90
 
65
91
 
92
+ def _escape_plain(text: str) -> str:
93
+ """Encode ``%`` and ``:`` so a stream name never runs into the group name after it."""
94
+ return text.replace("%", "%25").replace(":", "%3A")
95
+
96
+
66
97
  def _escape(text: str, colon: bool = False) -> str:
67
98
  """Percent-encode the characters that would end or split a hash tag."""
68
99
  out = text.replace("%", "%25").replace("{", "%7B").replace("}", "%7D")
@@ -75,11 +106,17 @@ class HashTagKeys(KeyBuilder):
75
106
  rk:{orders}:stream
76
107
  rk:{orders}:dlq (or rk:{orders}:dlq:<custom name>)
77
108
  rk:{orders}:workers:<group>:<consumer>
109
+ rk:{orders}:done:<idempotency key>
110
+ rk:{orders}:consumers:<group> (registry of live consumers)
78
111
 
79
112
  ``tenant`` adds an opaque id inside the tag (``rk:{t-42:orders}:stream``) so tenants' keys
80
113
  never collide. It is for placement and naming only, not access control: take it from trusted
81
- server-side context, never from a message. Braces, ``%`` and (in the tenant) ``:`` are
82
- percent-encoded, so no name can escape or split the tag.
114
+ server-side context, never from a message.
115
+
116
+ Inside the tag, ``%``, ``{``, ``}`` and ``:`` are percent-encoded in both the stream name and
117
+ the tenant. A tag therefore holds exactly one unencoded ``:`` when there is a tenant and none
118
+ when there is not, so no name can escape the tag, and no stream name can produce another
119
+ tenant's keys or a tenant-scoped key.
83
120
 
84
121
  A custom ``Stream(dlq=...)`` becomes a suffix of the stream's own tag, so two streams that
85
122
  share a DLQ name on a single server get one DLQ each here.
@@ -94,7 +131,10 @@ class HashTagKeys(KeyBuilder):
94
131
  self.tenant = tenant
95
132
 
96
133
  def tag(self, stream: Stream) -> str:
97
- name = _escape(stream.name)
134
+ if not stream.name:
135
+ # "{}" is not a hash tag, so the stream's keys would land in different slots
136
+ raise ConfigurationError("hash-tagged keys need a non-empty stream name")
137
+ name = _escape(stream.name, colon=True)
98
138
  if self.tenant is not None:
99
139
  name = f"{_escape(self.tenant, colon=True)}:{name}"
100
140
  return f"{{{name}}}"
@@ -112,6 +152,12 @@ class HashTagKeys(KeyBuilder):
112
152
  def heartbeat_prefix(self, stream: Stream, group: str) -> str:
113
153
  return f"{self._base(stream)}:workers:{group}:"
114
154
 
155
+ def idempotency(self, stream: Stream, key: str) -> str:
156
+ return f"{self._base(stream)}:done:{key}"
157
+
158
+ def consumers(self, stream: Stream, group: str) -> str:
159
+ return f"{self._base(stream)}:consumers:{group}"
160
+
115
161
  def __repr__(self) -> str:
116
162
  return f"HashTagKeys(prefix={self.prefix!r}, tenant={self.tenant!r})"
117
163
 
@@ -47,23 +47,30 @@ class BaseMiddleware:
47
47
  class Idempotent(BaseMiddleware):
48
48
  """Skip messages whose key was already processed successfully.
49
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.
50
+ Streams deliver at least once, so a crash after the handler finished but before the ack means
51
+ a redelivery. The key is stored with ``ttl`` seconds once the handler returns without raising.
52
52
 
53
53
  broker = Broker(middlewares=[Idempotent(key=lambda m: m.body["request_id"])])
54
+
55
+ By default the marker's key comes from the broker's key builder (``broker.keys.idempotency``):
56
+ ``rillkit:done:<stream>:<key>`` with plain keys, ``rk:{<stream>}:done:<key>`` with hash-tagged
57
+ keys, so markers follow the stream's prefix and tenant. Pass ``prefix`` to use
58
+ ``<prefix><stream>:<key>`` instead.
54
59
  """
55
60
 
56
61
  def __init__(
57
62
  self,
58
63
  key: Callable[[Message], str],
59
64
  ttl: int = 7 * 24 * 3600,
60
- prefix: str = "rillkit:done:",
65
+ prefix: Optional[str] = None,
61
66
  ):
62
67
  self.key = key
63
68
  self.ttl = ttl
64
69
  self.prefix = prefix
65
70
 
66
71
  def _redis_key(self, msg: Message) -> str:
72
+ if self.prefix is None:
73
+ return self.broker.keys.idempotency(msg.stream, self.key(msg))
67
74
  return f"{self.prefix}{msg.stream.name}:{self.key(msg)}"
68
75
 
69
76
  def consume_scope(self, call_next: Callable[[Message], Any], msg: Message) -> Any:
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  import os
2
3
  from collections.abc import Mapping, Sequence
3
4
  from dataclasses import dataclass, field, fields
@@ -8,7 +9,11 @@ from .clients import BACKENDS, MODES, Client, create_client, parse_node
8
9
  from .exceptions import ConfigurationError
9
10
  from .keys import KEY_LAYOUTS, HashTagKeys, KeyBuilder, PlainKeys, key_builder
10
11
 
12
+ log = logging.getLogger("rillkit")
13
+
11
14
  _SECRET_WORDS = ("password", "secret", "token", "credential")
15
+ # Fields a url replaces, so their variables are not used when one is set.
16
+ _URL_REPLACES = ("HOST", "PORT", "DB", "USERNAME", "PASSWORD", "SSL")
12
17
 
13
18
 
14
19
  def _seconds(value: Optional[str], default: Optional[float]) -> Optional[float]:
@@ -44,9 +49,10 @@ def redact_url(url: str) -> str:
44
49
  class Settings:
45
50
  """Connection settings: which server, which client library, and how to connect.
46
51
 
47
- ``Settings.from_env()`` reads REDIS_URL, or REDIS_HOST/PORT/DB/USERNAME/PASSWORD/SSL, plus
48
- REDIS_SOCKET_TIMEOUT (seconds, or ``none``), and the RILLKIT_URL, RILLKIT_BACKEND,
49
- RILLKIT_MODE, RILLKIT_CLUSTER_NODES and RILLKIT_KEY_LAYOUT variables.
52
+ ``Settings.from_env()`` reads RILLKIT_URL, or RILLKIT_HOST/PORT/DB/USERNAME/PASSWORD/SSL,
53
+ plus RILLKIT_SOCKET_TIMEOUT (seconds, or ``none``), RILLKIT_BACKEND, RILLKIT_MODE,
54
+ RILLKIT_CLUSTER_NODES and RILLKIT_KEY_LAYOUT. Each connection variable falls back to its
55
+ REDIS_ name (REDIS_URL, REDIS_HOST, ...), so 0.1 configurations keep working.
50
56
 
51
57
  ``socket_timeout`` is set explicitly because consumers block in ``XREADGROUP`` for up to
52
58
  ``block_ms`` and the client must wait longer than that. redis-py 8 changed its own default to
@@ -94,21 +100,34 @@ class Settings:
94
100
  def from_env(cls, prefix: str = "REDIS_", **overrides: Any) -> "Settings":
95
101
  """Settings from environment variables. Keyword arguments override what the env says.
96
102
 
97
- ``RILLKIT_URL`` wins over ``<prefix>URL``. Without either, the host/port/db/password
98
- variables are used. When a url is found, ``<prefix>USERNAME`` and ``<prefix>PASSWORD`` are
99
- not read, as before: put credentials in the url.
103
+ Every connection variable has a ``RILLKIT_`` name and an older ``<prefix>`` name
104
+ (``REDIS_`` by default); a non-empty ``RILLKIT_`` value wins. A url (``RILLKIT_URL``, else
105
+ ``<prefix>URL``) wins over the separate host, port, db, username, password and ssl
106
+ variables, which are then not read; setting one of their ``RILLKIT_`` names alongside a
107
+ url logs a warning.
100
108
  """
101
109
  env = os.environ
102
- url = env.get("RILLKIT_URL") or env.get(f"{prefix}URL") or None
110
+
111
+ def get(name: str, default: Optional[str] = None) -> Optional[str]:
112
+ value = env.get(f"RILLKIT_{name}")
113
+ if value:
114
+ return value
115
+ return env.get(f"{prefix}{name}", default)
116
+
117
+ url = get("URL") or None
118
+ if url:
119
+ ignored = [f"RILLKIT_{n}" for n in _URL_REPLACES if env.get(f"RILLKIT_{n}")]
120
+ if ignored:
121
+ log.warning("%s ignored: a url is set and provides them", ", ".join(ignored))
103
122
  values: dict[str, Any] = {
104
123
  "url": url,
105
- "host": env.get(f"{prefix}HOST", "localhost"),
106
- "port": int(env.get(f"{prefix}PORT", 6379)),
107
- "db": int(env.get(f"{prefix}DB", 0)),
108
- "password": None if url else env.get(f"{prefix}PASSWORD"),
109
- "username": None if url else env.get(f"{prefix}USERNAME"),
110
- "ssl": env.get(f"{prefix}SSL", "").lower() in ("1", "true", "yes"),
111
- "socket_timeout": _seconds(env.get(f"{prefix}SOCKET_TIMEOUT"), 15.0),
124
+ "host": get("HOST", "localhost"),
125
+ "port": int(get("PORT", "6379") or 6379),
126
+ "db": int(get("DB", "0") or 0),
127
+ "password": None if url else get("PASSWORD"),
128
+ "username": None if url else get("USERNAME"),
129
+ "ssl": (get("SSL", "") or "").lower() in ("1", "true", "yes"),
130
+ "socket_timeout": _seconds(get("SOCKET_TIMEOUT"), 15.0),
112
131
  "backend": env.get("RILLKIT_BACKEND") or "redis",
113
132
  "mode": env.get("RILLKIT_MODE") or "standalone",
114
133
  "cluster_nodes": _nodes(env.get("RILLKIT_CLUSTER_NODES")),
@@ -34,6 +34,16 @@ def hk(stream: StreamLike, group: str, consumer: str) -> str:
34
34
  return KEYS.heartbeat(as_stream(stream), group, consumer)
35
35
 
36
36
 
37
+ def ck(stream: StreamLike, group: str) -> str:
38
+ """Physical key of the consumer registry of a stream and group."""
39
+ return KEYS.consumers(as_stream(stream), group)
40
+
41
+
42
+ def ik(stream: StreamLike, key: str) -> str:
43
+ """Physical key of an Idempotent marker."""
44
+ return KEYS.idempotency(as_stream(stream), key)
45
+
46
+
37
47
  def wait_until(cond: Callable[[], Any], timeout: float = 10.0, interval: float = 0.02) -> bool:
38
48
  """Poll ``cond`` until it is truthy. Returns the final truthiness instead of raising."""
39
49
  deadline = time.monotonic() + timeout
@@ -0,0 +1,115 @@
1
+ """A whole worker running as a tenant ACL user that may touch only its own keys and not list any.
2
+
3
+ This is the isolation model for several tenants on one server or cluster: hash-tagged keys with a
4
+ tenant, and an allowlist ACL user per tenant. It proves the allowlist is enough for everything
5
+ rillkit does, and that it hides other tenants' keys, including their names.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from collections.abc import Iterator
11
+ from typing import Any, Callable
12
+
13
+ import pytest
14
+
15
+ from helpers import MODE, fast, wait_until
16
+ from rillkit import Broker, HashTagKeys, Idempotent, Message, Settings, Stream
17
+ from rillkit.clients import CLIENT_ERRORS
18
+
19
+ PREFIX = "acl"
20
+
21
+
22
+ def tenant_rule(tenant: str, password: str) -> list[str]:
23
+ """The per-tenant ACL rule documented in the Keys guide."""
24
+ scope = f"{PREFIX}:{{{tenant}:*"
25
+ return [
26
+ "on", f">{password}", "resetkeys", "resetchannels", f"~{scope}", f"&{scope}",
27
+ "-@all", "+@connection", "+@stream", "+@string", "+@hash", "+@sortedset", "+@transaction",
28
+ "+del", "+exists",
29
+ "+ssubscribe", "+sunsubscribe", "+spublish", "+subscribe", "+unsubscribe", "+publish",
30
+ "+cluster|slots", "+cluster|shards", "+cluster|nodes", "+readonly", "+readwrite",
31
+ ]
32
+
33
+
34
+ def _every_node(r: Any) -> list[Any]:
35
+ """ACL users are per server: on a cluster every node, replicas included, needs the user."""
36
+ if MODE != "cluster":
37
+ return [r]
38
+ return [
39
+ getattr(n, "redis_connection", None) or n.valkey_connection
40
+ for n in r.get_nodes()
41
+ ]
42
+
43
+
44
+ @pytest.fixture
45
+ def tenant_user(r: Any) -> Iterator[Callable[[str], tuple[str, str]]]:
46
+ created: list[str] = []
47
+
48
+ def make(tenant: str) -> tuple[str, str]:
49
+ user, password = f"rk-{tenant}-{uuid.uuid4().hex[:6]}", uuid.uuid4().hex
50
+ for node in _every_node(r):
51
+ node.execute_command("ACL", "SETUSER", user, *tenant_rule(tenant, password))
52
+ created.append(user)
53
+ return user, password
54
+
55
+ yield make
56
+ for user in created:
57
+ for node in _every_node(r):
58
+ node.execute_command("ACL", "DELUSER", user)
59
+
60
+
61
+ def test_a_full_worker_runs_as_a_tenant_acl_user(server_settings, tenant_user, r, caplog):
62
+ tenant = f"t-{uuid.uuid4().hex[:6]}"
63
+ user, password = tenant_user(tenant)
64
+ keys = HashTagKeys(prefix=PREFIX, tenant=tenant)
65
+ settings: Settings = server_settings(username=user, password=password)
66
+ broker = Broker(settings=settings, keys=keys,
67
+ middlewares=[Idempotent(key=lambda m: m.body["rid"])])
68
+ orders = Stream("orders", group="billing")
69
+ handled: list = []
70
+ attempts: list = []
71
+
72
+ @broker.subscribe(orders, consumer="worker-7", max_retries=1, **fast())
73
+ def handle(order: dict, msg: Message):
74
+ attempts.append(order["rid"])
75
+ if order.get("fail"):
76
+ raise ValueError("bad order")
77
+ handled.append(order["rid"])
78
+
79
+ with caplog.at_level(logging.ERROR, logger="rillkit"):
80
+ broker.start()
81
+ broker.publish(orders, {"rid": "a"})
82
+ broker.publish(orders, {"rid": "a"}) # duplicate, skipped by Idempotent
83
+ broker.publish(orders, {"rid": "b", "fail": True}) # retried once, then dead-lettered
84
+ assert wait_until(lambda: broker.client.xlen(keys.dlq(orders)) == 1, 15)
85
+ assert wait_until(lambda: broker.active_workers(orders) == ["worker-7"])
86
+ assert handled == ["a"]
87
+ assert attempts.count("b") == 2
88
+ assert broker.client.exists(keys.idempotency(orders, "a"))
89
+ broker.stop(5)
90
+ unexpected = [rec.getMessage() for rec in caplog.records
91
+ if "bad order" not in rec.getMessage()]
92
+ assert unexpected == [] # no permission errors in the loop, heartbeats or DLQ moves
93
+
94
+ replayer = Broker(settings=server_settings(username=user, password=password), keys=keys)
95
+ try:
96
+ assert replayer.replay_dlq(orders) == 1
97
+ assert replayer.active_workers(orders) == [] # removed from the registry on stop
98
+ finally:
99
+ replayer.client.close()
100
+
101
+
102
+ def test_a_tenant_user_cannot_see_other_tenants(server_settings, tenant_user, r):
103
+ other = HashTagKeys(prefix=PREFIX, tenant="other-tenant")
104
+ secret = other.stream(Stream("payroll"))
105
+ r.xadd(secret, {"data": "{}"})
106
+ user, password = tenant_user(f"t-{uuid.uuid4().hex[:6]}")
107
+ client = server_settings(username=user, password=password).client()
108
+ try:
109
+ for attempt in (lambda: client.xlen(secret), lambda: list(client.scan_iter("*")),
110
+ lambda: client.randomkey()):
111
+ with pytest.raises(CLIENT_ERRORS, match="(?i)noperm|permission"):
112
+ attempt()
113
+ finally:
114
+ client.close()
115
+ r.delete(secret)
@@ -76,7 +76,7 @@ def test_plain_keys_on_a_cluster_dead_letter_in_two_ordered_steps(
76
76
  broker.stop(5)
77
77
 
78
78
 
79
- def test_active_workers_scans_every_primary(server_settings, r, name):
79
+ def test_active_workers_finds_consumers_on_every_primary(server_settings, r, name):
80
80
  broker = Broker(settings=server_settings(key_layout="plain"))
81
81
  consumers = [f"c{i}" for i in range(12)]
82
82
  for c in consumers:
@@ -89,7 +89,7 @@ def test_active_workers_scans_every_primary(server_settings, r, name):
89
89
  assert len(owners) > 1 # the keys really are spread over several primaries
90
90
  finally:
91
91
  broker.stop(5)
92
- assert broker.active_workers(name, "g") == []
92
+ assert Broker(client=r, keys=PlainKeys()).active_workers(name, "g") == []
93
93
 
94
94
 
95
95
  def test_active_workers_on_an_empty_cluster_stream(broker, name):
@@ -0,0 +1,135 @@
1
+ import pytest
2
+ from redis.crc import key_slot
3
+
4
+ from rillkit import ConfigurationError, HashTagKeys, PlainKeys, Stream
5
+ from rillkit.keys import glob_escape
6
+
7
+
8
+ def slot(key: str) -> int:
9
+ return key_slot(key.encode())
10
+
11
+
12
+ def test_plain_keys_are_the_original_names():
13
+ keys = PlainKeys()
14
+ s = Stream("orders", group="g")
15
+ assert keys.stream(s) == "orders"
16
+ assert keys.dlq(s) == "orders-dlq"
17
+ assert keys.dlq(Stream("orders", dlq="dead")) == "dead"
18
+ assert keys.heartbeat(s, "g", "w1") == "rillkit:workers:orders:g:w1"
19
+
20
+
21
+ def test_hash_tag_layout():
22
+ keys = HashTagKeys()
23
+ s = Stream("orders")
24
+ assert keys.stream(s) == "rk:{orders}:stream"
25
+ assert keys.dlq(s) == "rk:{orders}:dlq"
26
+ assert keys.dlq(Stream("orders", dlq="dead")) == "rk:{orders}:dlq:dead"
27
+ assert keys.heartbeat(s, "g", "w1") == "rk:{orders}:workers:g:w1"
28
+
29
+
30
+ @pytest.mark.parametrize("name", ["orders", "a}b", "{x}", "a{b", "x:y", "100%", "}"])
31
+ def test_stream_dlq_and_heartbeats_share_a_slot(name):
32
+ keys = HashTagKeys(tenant="t-1")
33
+ for s in (Stream(name), Stream(name, dlq="custom}{")):
34
+ slots = {slot(keys.stream(s)), slot(keys.dlq(s)), slot(keys.heartbeat(s, "g", "c{1}"))}
35
+ assert len(slots) == 1, name
36
+
37
+
38
+ def test_idempotency_keys():
39
+ s = Stream("orders")
40
+ assert PlainKeys().idempotency(s, "req-1") == "rillkit:done:orders:req-1"
41
+ assert HashTagKeys().idempotency(s, "req-1") == "rk:{orders}:done:req-1"
42
+ assert (HashTagKeys(prefix="shop", tenant="t-42").idempotency(s, "req-1")
43
+ == "shop:{t-42:orders}:done:req-1")
44
+
45
+
46
+ def test_consumer_registry_keys():
47
+ s = Stream("orders:eu")
48
+ assert PlainKeys().consumers(s, "billing") == "rillkit:consumers:orders%3Aeu:billing"
49
+ assert HashTagKeys().consumers(Stream("orders"), "billing") == "rk:{orders}:consumers:billing"
50
+ assert (HashTagKeys(prefix="shop", tenant="t-42").consumers(Stream("orders"), "billing")
51
+ == "shop:{t-42:orders}:consumers:billing")
52
+ # a stream name with a colon can't run into the group name
53
+ assert (PlainKeys().consumers(Stream("a:b"), "c")
54
+ != PlainKeys().consumers(Stream("a"), "b:c"))
55
+
56
+
57
+ def test_custom_builders_keep_the_original_idempotency_key():
58
+ from rillkit import KeyBuilder
59
+
60
+ class Mine(KeyBuilder):
61
+ def stream(self, stream):
62
+ return f"x:{stream.name}"
63
+
64
+ assert Mine().idempotency(Stream("orders"), "k") == "rillkit:done:orders:k"
65
+
66
+
67
+ def test_colons_in_stream_names_are_encoded_inside_the_tag():
68
+ assert HashTagKeys().stream(Stream("orders:eu")) == "rk:{orders%3Aeu}:stream"
69
+ assert HashTagKeys(tenant="t-42").stream(Stream("orders:eu")) == "rk:{t-42:orders%3Aeu}:stream"
70
+ assert HashTagKeys().stream(Stream("orders")) == "rk:{orders}:stream" # unchanged
71
+
72
+
73
+ def test_no_stream_name_can_produce_a_tenant_key():
74
+ # the 0.2.0 collision: no tenant + "t-42:orders" == tenant "t-42" + "orders"
75
+ assert (HashTagKeys().stream(Stream("t-42:orders"))
76
+ != HashTagKeys(tenant="t-42").stream(Stream("orders")))
77
+
78
+
79
+ def test_keys_are_unique_across_tenants_and_names():
80
+ import itertools
81
+
82
+ parts = ["a", "b", ":", "a:b", "b:", ":a", "%", "%3A", "{", "}", "{a}", "a}b"]
83
+ tenants = [None, *parts]
84
+ seen = {}
85
+ for tenant, name in itertools.product(tenants, parts):
86
+ keys = HashTagKeys(tenant=tenant)
87
+ s = Stream(name, dlq="d")
88
+ for kind, key in (("stream", keys.stream(s)), ("dlq", keys.dlq(s)),
89
+ ("hb", keys.heartbeat(s, "g", "c")), ("done", keys.idempotency(s, "k")),
90
+ ("consumers", keys.consumers(s, "g"))):
91
+ assert seen.setdefault(key, (tenant, name, kind)) == (tenant, name, kind), key
92
+ assert len({slot(keys.stream(s)), slot(keys.dlq(s)), slot(keys.idempotency(s, "k")),
93
+ slot(keys.consumers(s, "g"))}) == 1
94
+
95
+
96
+ def test_no_stream_tag_can_equal_the_reserved_control_tag():
97
+ # Control-plane keys will live under {<tenant>:} ({:} without a tenant). A stream tag is
98
+ # {name} or {tenant:name} with a non-empty, colon-encoded name, so it never ends with ":".
99
+ import itertools
100
+
101
+ parts = ["a", ":", "a:", ":a", "%3A", "{", "}", "%", "t-42"]
102
+ for tenant, name in itertools.product([None, *parts], parts):
103
+ tag = HashTagKeys(tenant=tenant).tag(Stream(name))
104
+ assert not tag.endswith(":}"), (tenant, name, tag)
105
+ assert tag != "{:}"
106
+
107
+
108
+ def test_empty_stream_name_is_refused():
109
+ with pytest.raises(ConfigurationError, match="non-empty stream name"):
110
+ HashTagKeys().stream(Stream(""))
111
+
112
+
113
+ def test_braces_cannot_merge_two_streams():
114
+ keys = HashTagKeys()
115
+ assert keys.stream(Stream("a}b")) != keys.stream(Stream("a"))
116
+ assert keys.tag(Stream("a}b")) == "{a%7Db}"
117
+
118
+
119
+ def test_tenants_never_collide():
120
+ a = HashTagKeys(tenant="t:x").stream(Stream("y"))
121
+ b = HashTagKeys(tenant="t").stream(Stream("x:y"))
122
+ assert a != b
123
+ assert HashTagKeys(tenant="t-42").stream(Stream("orders")) == "rk:{t-42:orders}:stream"
124
+
125
+
126
+ def test_prefix_and_tenant_validation():
127
+ with pytest.raises(ConfigurationError):
128
+ HashTagKeys(prefix="a{b")
129
+ with pytest.raises(ConfigurationError):
130
+ HashTagKeys(tenant="")
131
+ assert HashTagKeys(prefix="").stream(Stream("o")) == "{o}:stream"
132
+
133
+
134
+ def test_glob_escape():
135
+ assert glob_escape("a*b?[c]\\") == "a\\*b\\?\\[c\\]\\\\"
@@ -212,7 +212,7 @@ def test_heartbeat_registered_while_running_and_removed_after_stop(broker: Broke
212
212
  assert 0 < r.ttl(key) <= 60
213
213
  broker.stop(timeout=5)
214
214
  assert not r.exists(key)
215
- assert broker.active_workers(name, "g") == []
215
+ assert Broker(client=r).active_workers(name, "g") == [] # a stopped broker's client is closed
216
216
 
217
217
 
218
218
  def test_active_workers_uses_stream_group_by_default(broker: Broker, name):
@@ -319,3 +319,41 @@ def test_injected_client_is_used_as_is(server_settings):
319
319
  assert broker.keys.stream(Stream("o")) == "app:{o}:stream"
320
320
  finally:
321
321
  client.close()
322
+
323
+
324
+ def test_registry_lists_live_consumers_without_scan(broker: Broker, r, name, monkeypatch):
325
+ import time as _time
326
+
327
+ from helpers import ck
328
+
329
+ @broker.subscribe(name, group="g", consumer="worker-7", heartbeat_ttl=30, **fast())
330
+ def handle(body): ...
331
+
332
+ def no_scan(*args, **kwargs):
333
+ raise AssertionError("active_workers must not SCAN")
334
+
335
+ monkeypatch.setattr(broker.client, "scan_iter", no_scan)
336
+ broker.start()
337
+ assert wait_until(lambda: broker.active_workers(name, "g") == ["worker-7"])
338
+ expires = r.zscore(ck(name, "g"), "worker-7")
339
+ assert 25_000 < expires - _time.time() * 1000 <= 30_000 # alive until now + heartbeat_ttl
340
+ broker.stop(timeout=5)
341
+ assert r.zscore(ck(name, "g"), "worker-7") is None # removed on a clean stop
342
+ assert Broker(client=r).active_workers(name, "g") == []
343
+
344
+
345
+ def test_registry_ignores_expired_entries_and_beats_prune_them(broker: Broker, r, name):
346
+ import time as _time
347
+
348
+ from helpers import ck
349
+
350
+ now_ms = int(_time.time() * 1000)
351
+ r.zadd(ck(name, "g"), {"crashed": now_ms - 1000, "alive-elsewhere": now_ms + 60_000})
352
+ assert broker.active_workers(name, "g") == ["alive-elsewhere"]
353
+
354
+ @broker.subscribe(name, group="g", consumer="worker-7", **fast())
355
+ def handle(body): ...
356
+
357
+ broker.start()
358
+ assert wait_until(lambda: r.zscore(ck(name, "g"), "crashed") is None) # pruned by a beat
359
+ assert broker.active_workers(name, "g") == ["alive-elsewhere", "worker-7"]
@@ -1,8 +1,17 @@
1
1
  import json
2
2
  from typing import Optional
3
3
 
4
- from helpers import dk, entries, fast, pending, read_count, settle, sk, wait_until
5
- from rillkit import AckMessage, BaseMiddleware, Idempotent, Message, PublishCommand, Stream
4
+ from helpers import dk, entries, fast, ik, pending, read_count, settle, sk, wait_until
5
+ from rillkit import (
6
+ AckMessage,
7
+ BaseMiddleware,
8
+ HashTagKeys,
9
+ Idempotent,
10
+ Message,
11
+ PlainKeys,
12
+ PublishCommand,
13
+ Stream,
14
+ )
6
15
 
7
16
 
8
17
  class Recorder(BaseMiddleware):
@@ -320,8 +329,8 @@ def test_idempotent_skips_duplicates(make_broker, r, name):
320
329
  broker.publish(name, {"rid": rid})
321
330
  assert wait_until(lambda: len(processed) == 5 and pending(r, name) == 0)
322
331
  assert handled == ["a", "b"]
323
- assert r.exists(f"rillkit:done:{name}:a")
324
- assert 0 < r.ttl(f"rillkit:done:{name}:a") <= 7 * 24 * 3600
332
+ assert r.exists(ik(name, "a"))
333
+ assert 0 < r.ttl(ik(name, "a")) <= 7 * 24 * 3600
325
334
 
326
335
 
327
336
  def test_idempotent_does_not_record_failed_attempts(make_broker, r, name):
@@ -340,3 +349,50 @@ def test_idempotent_does_not_record_failed_attempts(make_broker, r, name):
340
349
  assert wait_until(lambda: len(attempts) == 2 and pending(r, name) == 0)
341
350
  assert r.exists(f"idem:{name}:x")
342
351
  assert r.ttl(f"idem:{name}:x") <= 60
352
+
353
+
354
+ def test_idempotent_markers_follow_the_broker_key_builder(make_broker, r, name):
355
+ done = []
356
+ broker = make_broker(
357
+ keys=HashTagKeys(prefix="app", tenant="t-1"),
358
+ middlewares=[Idempotent(key=lambda m: m.body["rid"])],
359
+ )
360
+
361
+ @broker.subscribe(name, group="g", **fast())
362
+ def handle(body):
363
+ done.append(body["rid"])
364
+
365
+ broker.start()
366
+ broker.publish(name, {"rid": "a"})
367
+ assert wait_until(lambda: done == ["a"])
368
+ marker = f"app:{{t-1:{name}}}:done:a"
369
+ assert wait_until(lambda: r.exists(marker))
370
+ assert not r.exists(f"rillkit:done:{name}:a")
371
+
372
+
373
+ def test_tenants_do_not_share_idempotent_markers(make_broker, r, name):
374
+ handled = {"t-1": [], "t-2": []}
375
+ brokers = []
376
+ for tenant in handled:
377
+ b = make_broker(keys=HashTagKeys(tenant=tenant),
378
+ middlewares=[Idempotent(key=lambda m: m.body["rid"])])
379
+ def make_handler(t):
380
+ def handle(body):
381
+ handled[t].append(body["rid"])
382
+ return handle
383
+
384
+ b.subscribe(name, group="g", consumer=tenant, **fast())(make_handler(tenant))
385
+ b.start()
386
+ brokers.append(b)
387
+ for b in brokers:
388
+ b.publish(name, {"rid": "same-request"}) # same stream name and key, per tenant
389
+ assert wait_until(lambda: handled == {"t-1": ["same-request"], "t-2": ["same-request"]})
390
+
391
+
392
+ def test_idempotent_with_plain_keys_keeps_the_original_marker(make_broker, r, name):
393
+ broker = make_broker(keys=PlainKeys(), middlewares=[Idempotent(key=lambda m: m.body["rid"])])
394
+ done = []
395
+ broker.subscribe(name, group="g", **fast())(lambda body: done.append(1))
396
+ broker.start()
397
+ broker.publish(name, {"rid": "a"})
398
+ assert wait_until(lambda: done and r.exists(f"rillkit:done:{name}:a"))
@@ -6,7 +6,7 @@ from helpers import BACKEND
6
6
  from rillkit import Broker, ConfigurationError, HashTagKeys, PlainKeys, Settings
7
7
 
8
8
  KEYS = ["URL", "HOST", "PORT", "DB", "USERNAME", "PASSWORD", "SSL", "SOCKET_TIMEOUT"]
9
- RILLKIT_KEYS = ["URL", "BACKEND", "MODE", "CLUSTER_NODES", "KEY_LAYOUT"]
9
+ RILLKIT_KEYS = ["URL", "BACKEND", "MODE", "CLUSTER_NODES", "KEY_LAYOUT"] + KEYS[1:]
10
10
 
11
11
 
12
12
  @pytest.fixture(autouse=True)
@@ -143,6 +143,66 @@ def test_keyword_arguments_override_the_environment(monkeypatch):
143
143
  assert (s.backend, s.host, s.client_kwargs) == ("redis", "explicit", {"client_name": "x"})
144
144
 
145
145
 
146
+ def test_rillkit_connection_variables(monkeypatch):
147
+ for key, value in {"HOST": "rk.internal", "PORT": "6390", "DB": "4", "USERNAME": "app",
148
+ "PASSWORD": "pw", "SSL": "yes", "SOCKET_TIMEOUT": "30"}.items():
149
+ monkeypatch.setenv(f"RILLKIT_{key}", value)
150
+ s = Settings.from_env()
151
+ assert (s.url, s.host, s.port, s.db) == (None, "rk.internal", 6390, 4)
152
+ assert (s.username, s.password, s.ssl, s.socket_timeout) == ("app", "pw", True, 30.0)
153
+ kwargs = kwargs_of(s)
154
+ assert (kwargs["host"], kwargs["port"], kwargs["db"]) == ("rk.internal", 6390, 4)
155
+ assert (kwargs["username"], kwargs["password"]) == ("app", "pw")
156
+
157
+
158
+ @pytest.mark.parametrize("key,rillkit,legacy,field,expected", [
159
+ ("HOST", "new", "old", "host", "new"),
160
+ ("PORT", "7000", "6000", "port", 7000),
161
+ ("DB", "2", "1", "db", 2),
162
+ ("USERNAME", "new", "old", "username", "new"),
163
+ ("PASSWORD", "new", "old", "password", "new"),
164
+ ("SSL", "true", "false", "ssl", True),
165
+ ("SSL", "false", "true", "ssl", False),
166
+ ("SOCKET_TIMEOUT", "none", "30", "socket_timeout", None),
167
+ ])
168
+ def test_rillkit_variable_wins_over_its_redis_name(monkeypatch, key, rillkit, legacy, field,
169
+ expected):
170
+ monkeypatch.setenv(f"RILLKIT_{key}", rillkit)
171
+ monkeypatch.setenv(f"REDIS_{key}", legacy)
172
+ assert getattr(Settings.from_env(), field) == expected
173
+ monkeypatch.setenv(f"MYAPP_{key}", legacy)
174
+ assert getattr(Settings.from_env("MYAPP_"), field) == expected
175
+
176
+
177
+ def test_empty_rillkit_variable_falls_back_to_the_redis_name(monkeypatch):
178
+ monkeypatch.setenv("RILLKIT_HOST", "")
179
+ monkeypatch.setenv("REDIS_HOST", "legacy")
180
+ assert Settings.from_env().host == "legacy"
181
+
182
+
183
+ def test_redis_variables_still_work_without_rillkit_ones(monkeypatch):
184
+ monkeypatch.setenv("REDIS_HOST", "legacy")
185
+ monkeypatch.setenv("REDIS_SOCKET_TIMEOUT", "none")
186
+ s = Settings.from_env()
187
+ assert (s.host, s.socket_timeout) == ("legacy", None)
188
+
189
+
190
+ def test_rillkit_fields_next_to_a_url_are_ignored_with_a_warning(monkeypatch, caplog):
191
+ import logging
192
+
193
+ monkeypatch.setenv("REDIS_URL", "redis://from-url:1234/0")
194
+ monkeypatch.setenv("RILLKIT_HOST", "ignored")
195
+ monkeypatch.setenv("RILLKIT_PASSWORD", "ignored-secret")
196
+ monkeypatch.setenv("RILLKIT_SOCKET_TIMEOUT", "30") # not replaced by a url, so used
197
+ with caplog.at_level(logging.WARNING, logger="rillkit"):
198
+ s = Settings.from_env()
199
+ assert (s.url, s.password, s.socket_timeout) == ("redis://from-url:1234/0", None, 30.0)
200
+ assert kwargs_of(s)["host"] == "from-url"
201
+ [record] = caplog.records
202
+ assert "RILLKIT_HOST" in record.getMessage() and "RILLKIT_PASSWORD" in record.getMessage()
203
+ assert "ignored-secret" not in record.getMessage()
204
+
205
+
146
206
  def test_username_from_env_without_url(monkeypatch):
147
207
  monkeypatch.setenv("REDIS_USERNAME", "app")
148
208
  monkeypatch.setenv("REDIS_PASSWORD", "pw")
@@ -1,60 +0,0 @@
1
- import pytest
2
- from redis.crc import key_slot
3
-
4
- from rillkit import ConfigurationError, HashTagKeys, PlainKeys, Stream
5
- from rillkit.keys import glob_escape
6
-
7
-
8
- def slot(key: str) -> int:
9
- return key_slot(key.encode())
10
-
11
-
12
- def test_plain_keys_are_the_original_names():
13
- keys = PlainKeys()
14
- s = Stream("orders", group="g")
15
- assert keys.stream(s) == "orders"
16
- assert keys.dlq(s) == "orders-dlq"
17
- assert keys.dlq(Stream("orders", dlq="dead")) == "dead"
18
- assert keys.heartbeat(s, "g", "w1") == "rillkit:workers:orders:g:w1"
19
-
20
-
21
- def test_hash_tag_layout():
22
- keys = HashTagKeys()
23
- s = Stream("orders")
24
- assert keys.stream(s) == "rk:{orders}:stream"
25
- assert keys.dlq(s) == "rk:{orders}:dlq"
26
- assert keys.dlq(Stream("orders", dlq="dead")) == "rk:{orders}:dlq:dead"
27
- assert keys.heartbeat(s, "g", "w1") == "rk:{orders}:workers:g:w1"
28
-
29
-
30
- @pytest.mark.parametrize("name", ["orders", "a}b", "{x}", "a{b", "x:y", "100%", "}", ""])
31
- def test_stream_dlq_and_heartbeats_share_a_slot(name):
32
- keys = HashTagKeys(tenant="t-1")
33
- for s in (Stream(name), Stream(name, dlq="custom}{")):
34
- slots = {slot(keys.stream(s)), slot(keys.dlq(s)), slot(keys.heartbeat(s, "g", "c{1}"))}
35
- assert len(slots) == 1, name
36
-
37
-
38
- def test_braces_cannot_merge_two_streams():
39
- keys = HashTagKeys()
40
- assert keys.stream(Stream("a}b")) != keys.stream(Stream("a"))
41
- assert keys.tag(Stream("a}b")) == "{a%7Db}"
42
-
43
-
44
- def test_tenants_never_collide():
45
- a = HashTagKeys(tenant="t:x").stream(Stream("y"))
46
- b = HashTagKeys(tenant="t").stream(Stream("x:y"))
47
- assert a != b
48
- assert HashTagKeys(tenant="t-42").stream(Stream("orders")) == "rk:{t-42:orders}:stream"
49
-
50
-
51
- def test_prefix_and_tenant_validation():
52
- with pytest.raises(ConfigurationError):
53
- HashTagKeys(prefix="a{b")
54
- with pytest.raises(ConfigurationError):
55
- HashTagKeys(tenant="")
56
- assert HashTagKeys(prefix="").stream(Stream("o")) == "{o}:stream"
57
-
58
-
59
- def test_glob_escape():
60
- assert glob_escape("a*b?[c]\\") == "a\\*b\\?\\[c\\]\\\\"
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