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.
- {rillkit-0.2.0 → rillkit-0.3.0}/PKG-INFO +12 -10
- {rillkit-0.2.0 → rillkit-0.3.0}/README.md +7 -5
- {rillkit-0.2.0 → rillkit-0.3.0}/pyproject.toml +5 -5
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/__init__.py +1 -1
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/broker.py +9 -7
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/consumer.py +16 -2
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/keys.py +49 -3
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/middleware.py +10 -3
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/settings.py +33 -14
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/helpers.py +10 -0
- rillkit-0.3.0/tests/test_acl.py +115 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_cluster.py +2 -2
- rillkit-0.3.0/tests/test_keys.py +135 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_lifecycle.py +39 -1
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_middleware.py +60 -4
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_settings.py +61 -1
- rillkit-0.2.0/tests/test_keys.py +0 -60
- {rillkit-0.2.0 → rillkit-0.3.0}/.gitignore +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/LICENSE +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/ack.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/clients.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/codec.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/exceptions.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/message.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/py.typed +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/src/rillkit/stream.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/conftest.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_blocking_timeout.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_consume.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_publish.py +0 -0
- {rillkit-0.2.0 → rillkit-0.3.0}/tests/test_publisher_decorator.py +0 -0
- {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.
|
|
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/
|
|
6
|
-
Project-URL: Documentation, https://nimzymaina.github.io/
|
|
7
|
-
Project-URL: Repository, https://github.com/NimzyMaina/
|
|
8
|
-
Project-URL: Issues, https://github.com/NimzyMaina/
|
|
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/
|
|
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() #
|
|
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`)
|
|
77
|
-
|
|
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/
|
|
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() #
|
|
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`)
|
|
46
|
-
|
|
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.
|
|
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/
|
|
35
|
-
Documentation = "https://nimzymaina.github.io/
|
|
36
|
-
Repository = "https://github.com/NimzyMaina/
|
|
37
|
-
Issues = "https://github.com/NimzyMaina/
|
|
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.
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
return sorted(
|
|
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.
|
|
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.
|
|
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.
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
``
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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":
|
|
106
|
-
"port": int(
|
|
107
|
-
"db": int(
|
|
108
|
-
"password": None if url else
|
|
109
|
-
"username": None if url else
|
|
110
|
-
"ssl":
|
|
111
|
-
"socket_timeout": _seconds(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
324
|
-
assert 0 < r.ttl(
|
|
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")
|
rillkit-0.2.0/tests/test_keys.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|