muscles-data-redis 0.1.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.
- muscles_data_redis-0.1.0/PKG-INFO +81 -0
- muscles_data_redis-0.1.0/README.md +69 -0
- muscles_data_redis-0.1.0/pyproject.toml +32 -0
- muscles_data_redis-0.1.0/setup.cfg +4 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis/__init__.py +20 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis/adapter.py +419 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis.egg-info/PKG-INFO +81 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis.egg-info/SOURCES.txt +11 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis.egg-info/dependency_links.txt +1 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis.egg-info/requires.txt +5 -0
- muscles_data_redis-0.1.0/src/muscles_data_redis.egg-info/top_level.txt +1 -0
- muscles_data_redis-0.1.0/tests/test_integration.py +61 -0
- muscles_data_redis-0.1.0/tests/test_redis_adapter.py +223 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-data-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis KeyValuePort, LockPort and StreamPort adapter for muscles-data
|
|
5
|
+
Author-email: "Denis B." <denis@butko.info>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: muscles-data<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: redis<9.0,>=5.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# muscles-data-redis
|
|
14
|
+
|
|
15
|
+
Redis adapter package for `muscles-data`.
|
|
16
|
+
|
|
17
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
18
|
+
owns typed ports, resource runtime and diagnostics, while this package owns
|
|
19
|
+
Redis-backed `KeyValuePort`, `LockPort` and `StreamPort` implementations.
|
|
20
|
+
|
|
21
|
+
## Related packages
|
|
22
|
+
|
|
23
|
+
- Core runtime and port contracts:
|
|
24
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
25
|
+
- Elasticsearch search adapter:
|
|
26
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
27
|
+
- OpenSearch search adapter:
|
|
28
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
29
|
+
- Qdrant vector adapter:
|
|
30
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
31
|
+
- MongoDB document-store adapter:
|
|
32
|
+
[`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
|
|
33
|
+
- S3 object-store adapter:
|
|
34
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
35
|
+
- SQLAlchemy direct SQL resource adapter:
|
|
36
|
+
[`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
|
|
37
|
+
- Executable example:
|
|
38
|
+
[`example_data_redis_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_redis_1)
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Register the factory in the project composition root:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
46
|
+
from muscles_data.ports import KeyValuePort
|
|
47
|
+
from muscles_data.runtime import DataRuntime
|
|
48
|
+
from muscles_data_redis import RedisDataFactory
|
|
49
|
+
|
|
50
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
51
|
+
catalog.register(RedisDataFactory())
|
|
52
|
+
|
|
53
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
54
|
+
cache = runtime.require_port("cache.redis", KeyValuePort)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Resource config stays in the project:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
data:
|
|
61
|
+
resources:
|
|
62
|
+
cache.redis:
|
|
63
|
+
type: redis
|
|
64
|
+
url_env: REDIS_URL
|
|
65
|
+
namespace: app
|
|
66
|
+
timeout: 3
|
|
67
|
+
stream_group: workers
|
|
68
|
+
consumer: index-worker
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The adapter creates the Redis client lazily by key-value, lock, stream, explicit
|
|
72
|
+
native access or `data.doctor` operations. Application code should use
|
|
73
|
+
`KeyValuePort`, `LockPort` and `StreamPort`; direct client access is only an
|
|
74
|
+
advanced escape hatch with `native_client: true`.
|
|
75
|
+
|
|
76
|
+
Streams use `XADD`, `XREADGROUP` and `XACK`. The configured consumer group is
|
|
77
|
+
created on first read and messages use the versioned
|
|
78
|
+
`muscles.data.message.v1` envelope. Retry and dead-letter decisions stay with
|
|
79
|
+
the application worker.
|
|
80
|
+
|
|
81
|
+
See `muscular-example/example_data_redis_1` for an executable example.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# muscles-data-redis
|
|
2
|
+
|
|
3
|
+
Redis adapter package for `muscles-data`.
|
|
4
|
+
|
|
5
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
6
|
+
owns typed ports, resource runtime and diagnostics, while this package owns
|
|
7
|
+
Redis-backed `KeyValuePort`, `LockPort` and `StreamPort` implementations.
|
|
8
|
+
|
|
9
|
+
## Related packages
|
|
10
|
+
|
|
11
|
+
- Core runtime and port contracts:
|
|
12
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
13
|
+
- Elasticsearch search adapter:
|
|
14
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
15
|
+
- OpenSearch search adapter:
|
|
16
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
17
|
+
- Qdrant vector adapter:
|
|
18
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
19
|
+
- MongoDB document-store adapter:
|
|
20
|
+
[`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
|
|
21
|
+
- S3 object-store adapter:
|
|
22
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
23
|
+
- SQLAlchemy direct SQL resource adapter:
|
|
24
|
+
[`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
|
|
25
|
+
- Executable example:
|
|
26
|
+
[`example_data_redis_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_redis_1)
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
Register the factory in the project composition root:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
34
|
+
from muscles_data.ports import KeyValuePort
|
|
35
|
+
from muscles_data.runtime import DataRuntime
|
|
36
|
+
from muscles_data_redis import RedisDataFactory
|
|
37
|
+
|
|
38
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
39
|
+
catalog.register(RedisDataFactory())
|
|
40
|
+
|
|
41
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
42
|
+
cache = runtime.require_port("cache.redis", KeyValuePort)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Resource config stays in the project:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
data:
|
|
49
|
+
resources:
|
|
50
|
+
cache.redis:
|
|
51
|
+
type: redis
|
|
52
|
+
url_env: REDIS_URL
|
|
53
|
+
namespace: app
|
|
54
|
+
timeout: 3
|
|
55
|
+
stream_group: workers
|
|
56
|
+
consumer: index-worker
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The adapter creates the Redis client lazily by key-value, lock, stream, explicit
|
|
60
|
+
native access or `data.doctor` operations. Application code should use
|
|
61
|
+
`KeyValuePort`, `LockPort` and `StreamPort`; direct client access is only an
|
|
62
|
+
advanced escape hatch with `native_client: true`.
|
|
63
|
+
|
|
64
|
+
Streams use `XADD`, `XREADGROUP` and `XACK`. The configured consumer group is
|
|
65
|
+
created on first read and messages use the versioned
|
|
66
|
+
`muscles.data.message.v1` envelope. Retry and dead-letter decisions stay with
|
|
67
|
+
the application worker.
|
|
68
|
+
|
|
69
|
+
See `muscular-example/example_data_redis_1` for an executable example.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "muscles-data-redis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Redis KeyValuePort, LockPort and StreamPort adapter for muscles-data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Denis B.", email = "denis@butko.info" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"muscles-data>=0.1.0,<1.0.0",
|
|
14
|
+
"redis>=5.0,<9.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0,<9.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.pytest.ini_options]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
markers = [
|
|
25
|
+
"integration: tests that require the local backend integration stack",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
package-dir = {"" = "src"}
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .adapter import (
|
|
4
|
+
RedisAdapterError,
|
|
5
|
+
RedisClientMissingError,
|
|
6
|
+
RedisConfigError,
|
|
7
|
+
RedisConnectionError,
|
|
8
|
+
RedisDataAdapter,
|
|
9
|
+
RedisDataFactory,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"RedisAdapterError",
|
|
15
|
+
"RedisClientMissingError",
|
|
16
|
+
"RedisConfigError",
|
|
17
|
+
"RedisConnectionError",
|
|
18
|
+
"RedisDataAdapter",
|
|
19
|
+
"RedisDataFactory",
|
|
20
|
+
]
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from muscles_data.config import DataResourceConfig
|
|
14
|
+
from muscles_data.errors import DataConfigurationError, DataConnectionError, DataError
|
|
15
|
+
from muscles_data.models import DataCapability, HealthResult, InspectResult, LockHandle, StreamReadResult, WriteResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_CLIENT_UNSET = object()
|
|
19
|
+
_ALLOWED_OPTIONS = {
|
|
20
|
+
"url",
|
|
21
|
+
"url_env",
|
|
22
|
+
"namespace",
|
|
23
|
+
"decode_responses",
|
|
24
|
+
"timeout",
|
|
25
|
+
"socket_timeout",
|
|
26
|
+
"socket_connect_timeout",
|
|
27
|
+
"stream_group",
|
|
28
|
+
"consumer",
|
|
29
|
+
"native_client",
|
|
30
|
+
}
|
|
31
|
+
_LOCK_RELEASE_SCRIPT = """
|
|
32
|
+
local token = redis.call('get', KEYS[1])
|
|
33
|
+
if not token or token ~= ARGV[1] then
|
|
34
|
+
return 0
|
|
35
|
+
end
|
|
36
|
+
redis.call('del', KEYS[1])
|
|
37
|
+
return 1
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RedisAdapterError(DataError):
|
|
42
|
+
"""Base error for Redis data adapter failures."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RedisConfigError(ValueError, DataConfigurationError, RedisAdapterError):
|
|
46
|
+
"""Raised when a Redis resource config cannot be mapped safely."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RedisClientMissingError(DataConfigurationError, RedisAdapterError):
|
|
50
|
+
"""Raised when Redis adapter is used without an available client."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RedisConnectionError(DataConnectionError, RedisAdapterError):
|
|
54
|
+
"""Raised when a Redis operation cannot reach or use the backend."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class RedisDataAdapter:
|
|
58
|
+
resource_type = "redis"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
config: DataResourceConfig,
|
|
63
|
+
*,
|
|
64
|
+
client_factory: Callable[[DataResourceConfig], Any] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.config = config
|
|
67
|
+
self._client_factory = client_factory
|
|
68
|
+
self._client: Any = _CLIENT_UNSET
|
|
69
|
+
self._lock = threading.RLock()
|
|
70
|
+
self.closed = False
|
|
71
|
+
|
|
72
|
+
def get(self, key: str) -> bytes | None:
|
|
73
|
+
try:
|
|
74
|
+
value = self._client_instance().get(self._key(key))
|
|
75
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
76
|
+
raise
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
79
|
+
return _bytes_or_none(value)
|
|
80
|
+
|
|
81
|
+
def set(self, key: str, value: bytes, ttl_seconds: float | None = None) -> WriteResult:
|
|
82
|
+
kwargs: dict[str, Any] = {}
|
|
83
|
+
if ttl_seconds is not None:
|
|
84
|
+
kwargs["px"] = _ttl_ms(ttl_seconds, "Redis key ttl_seconds")
|
|
85
|
+
try:
|
|
86
|
+
written = bool(self._client_instance().set(self._key(key), bytes(value), **kwargs))
|
|
87
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
88
|
+
raise
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
91
|
+
return WriteResult(written=1 if written else 0, matched=1 if written else 0)
|
|
92
|
+
|
|
93
|
+
def delete(self, key: str) -> WriteResult:
|
|
94
|
+
try:
|
|
95
|
+
deleted = int(self._client_instance().delete(self._key(key)) or 0)
|
|
96
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
97
|
+
raise
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
100
|
+
return WriteResult(deleted=deleted, matched=deleted)
|
|
101
|
+
|
|
102
|
+
def exists(self, key: str) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
return bool(self._client_instance().exists(self._key(key)))
|
|
105
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
106
|
+
raise
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
109
|
+
|
|
110
|
+
def acquire_lock(self, name: str, ttl_seconds: float):
|
|
111
|
+
ttl_ms = _ttl_ms(ttl_seconds, "Redis lock ttl_seconds")
|
|
112
|
+
token = uuid.uuid4().hex
|
|
113
|
+
lock_key = self._lock_key(name)
|
|
114
|
+
try:
|
|
115
|
+
acquired = bool(self._client_instance().set(lock_key, token, nx=True, px=ttl_ms))
|
|
116
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
117
|
+
raise
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
120
|
+
if not acquired:
|
|
121
|
+
return None
|
|
122
|
+
return LockHandle(name=str(name), token=token, expires_at=time.monotonic() + float(ttl_seconds))
|
|
123
|
+
|
|
124
|
+
def release_lock(self, handle) -> WriteResult:
|
|
125
|
+
if handle is None:
|
|
126
|
+
return WriteResult()
|
|
127
|
+
name = getattr(handle, "name", None)
|
|
128
|
+
token = getattr(handle, "token", None)
|
|
129
|
+
if not name or not token:
|
|
130
|
+
raise RedisConfigError("Redis lock handle requires name and token")
|
|
131
|
+
try:
|
|
132
|
+
deleted = int(self._client_instance().eval(_LOCK_RELEASE_SCRIPT, 1, self._lock_key(str(name)), str(token)) or 0)
|
|
133
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
134
|
+
raise
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
137
|
+
matched = 1 if deleted else 0
|
|
138
|
+
return WriteResult(deleted=matched, matched=matched)
|
|
139
|
+
|
|
140
|
+
def publish(self, stream: str, message: Mapping[str, Any]) -> WriteResult:
|
|
141
|
+
try:
|
|
142
|
+
message_id = self._client_instance().xadd(self._stream_key(stream), _stream_fields(message))
|
|
143
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
144
|
+
raise
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
147
|
+
return WriteResult(written=1, matched=1, message_id=_text(message_id))
|
|
148
|
+
|
|
149
|
+
def read(self, stream: str, cursor: str | None = None, limit: int = 100) -> StreamReadResult:
|
|
150
|
+
if limit <= 0:
|
|
151
|
+
return StreamReadResult(messages=[], cursor=cursor)
|
|
152
|
+
stream_name = str(stream)
|
|
153
|
+
stream_key = self._stream_key(stream_name)
|
|
154
|
+
try:
|
|
155
|
+
self._ensure_group(stream_key)
|
|
156
|
+
response = self._client_instance().xreadgroup(
|
|
157
|
+
self.stream_group(),
|
|
158
|
+
self.consumer_name(),
|
|
159
|
+
{stream_key: cursor or ">"},
|
|
160
|
+
count=max(0, int(limit)),
|
|
161
|
+
)
|
|
162
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
163
|
+
raise
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
166
|
+
|
|
167
|
+
messages: list[dict[str, Any]] = []
|
|
168
|
+
next_cursor = cursor
|
|
169
|
+
for _raw_name, entries in response or []:
|
|
170
|
+
for message_id, fields in entries:
|
|
171
|
+
normalized_id = _text(message_id)
|
|
172
|
+
messages.append(
|
|
173
|
+
{
|
|
174
|
+
"stream": stream_name,
|
|
175
|
+
"id": normalized_id,
|
|
176
|
+
**_decode_envelope(fields),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
next_cursor = normalized_id
|
|
180
|
+
return StreamReadResult(messages=messages, cursor=next_cursor)
|
|
181
|
+
|
|
182
|
+
def ack(self, stream: str, message_id: str) -> WriteResult:
|
|
183
|
+
group = self.stream_group()
|
|
184
|
+
try:
|
|
185
|
+
matched = int(self._client_instance().xack(self._stream_key(stream), group, str(message_id)) or 0)
|
|
186
|
+
except (RedisClientMissingError, RedisConfigError):
|
|
187
|
+
raise
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
190
|
+
return WriteResult(matched=matched)
|
|
191
|
+
|
|
192
|
+
def inspect(self) -> dict[str, Any]:
|
|
193
|
+
return asdict(
|
|
194
|
+
InspectResult(
|
|
195
|
+
name=self.config.name,
|
|
196
|
+
type=self.config.type,
|
|
197
|
+
capabilities=[],
|
|
198
|
+
initialized=self._client is not _CLIENT_UNSET,
|
|
199
|
+
status="ok",
|
|
200
|
+
options=self.config.safe_options(),
|
|
201
|
+
details={
|
|
202
|
+
"backend": "redis",
|
|
203
|
+
"namespace_enabled": bool(self.namespace()),
|
|
204
|
+
"stream_group": self.stream_group(),
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def health(self) -> HealthResult:
|
|
210
|
+
try:
|
|
211
|
+
ping = bool(self._client_instance().ping())
|
|
212
|
+
except DataConfigurationError as exc:
|
|
213
|
+
return HealthResult(status="failed", code="configuration_error", message=str(exc))
|
|
214
|
+
except Exception as exc:
|
|
215
|
+
return HealthResult(status="failed", code="connection_failed", message=self._safe_error(exc))
|
|
216
|
+
if not ping:
|
|
217
|
+
return HealthResult(status="failed", code="connection_failed", message="Redis ping failed")
|
|
218
|
+
return HealthResult(status="ok", code="ok", message="Redis connection is available")
|
|
219
|
+
|
|
220
|
+
def close(self) -> None:
|
|
221
|
+
if self._client is _CLIENT_UNSET:
|
|
222
|
+
self.closed = True
|
|
223
|
+
return
|
|
224
|
+
close = getattr(self._client, "close", None)
|
|
225
|
+
if callable(close):
|
|
226
|
+
close()
|
|
227
|
+
self.closed = True
|
|
228
|
+
|
|
229
|
+
def native_client(self):
|
|
230
|
+
return self._client_instance()
|
|
231
|
+
|
|
232
|
+
def namespace(self) -> str:
|
|
233
|
+
return str(self.config.options.get("namespace", "")).strip(":")
|
|
234
|
+
|
|
235
|
+
def stream_group(self) -> str:
|
|
236
|
+
group = str(self.config.options.get("stream_group", "default"))
|
|
237
|
+
if not group:
|
|
238
|
+
raise RedisConfigError("Redis stream_group must not be empty")
|
|
239
|
+
return group
|
|
240
|
+
|
|
241
|
+
def consumer_name(self) -> str:
|
|
242
|
+
consumer = str(self.config.options.get("consumer", "muscles-worker"))
|
|
243
|
+
if not consumer:
|
|
244
|
+
raise RedisConfigError("Redis consumer must not be empty")
|
|
245
|
+
return consumer
|
|
246
|
+
|
|
247
|
+
def _ensure_group(self, stream_key: str) -> None:
|
|
248
|
+
create = getattr(self._client_instance(), "xgroup_create", None)
|
|
249
|
+
if not callable(create):
|
|
250
|
+
raise RedisConfigError("Redis client does not support consumer groups")
|
|
251
|
+
try:
|
|
252
|
+
create(stream_key, self.stream_group(), id="0-0", mkstream=True)
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
if "busygroup" not in str(exc).lower() and "already exists" not in str(exc).lower():
|
|
255
|
+
raise RedisConnectionError(self._safe_error(exc)) from exc
|
|
256
|
+
|
|
257
|
+
def _key(self, key: str) -> str:
|
|
258
|
+
normalized = _name(key, "Redis key")
|
|
259
|
+
namespace = self.namespace()
|
|
260
|
+
return f"{namespace}:{normalized}" if namespace else normalized
|
|
261
|
+
|
|
262
|
+
def _lock_key(self, name: str) -> str:
|
|
263
|
+
return self._key(f"lock:{_name(name, 'Redis lock name')}")
|
|
264
|
+
|
|
265
|
+
def _stream_key(self, stream: str) -> str:
|
|
266
|
+
return self._key(f"stream:{_name(stream, 'Redis stream name')}")
|
|
267
|
+
|
|
268
|
+
def _client_instance(self):
|
|
269
|
+
if self._client is _CLIENT_UNSET:
|
|
270
|
+
with self._lock:
|
|
271
|
+
if self._client is _CLIENT_UNSET:
|
|
272
|
+
self._validate_options()
|
|
273
|
+
client = (
|
|
274
|
+
self._client_factory(self.config)
|
|
275
|
+
if self._client_factory
|
|
276
|
+
else _default_redis_client(self.config)
|
|
277
|
+
)
|
|
278
|
+
if client is None:
|
|
279
|
+
raise RedisClientMissingError("Redis client is not available")
|
|
280
|
+
self._client = client
|
|
281
|
+
return self._client
|
|
282
|
+
|
|
283
|
+
def _validate_options(self) -> None:
|
|
284
|
+
unknown = sorted(set(self.config.options) - _ALLOWED_OPTIONS)
|
|
285
|
+
if unknown:
|
|
286
|
+
names = ", ".join(unknown)
|
|
287
|
+
raise RedisConfigError(f"Unsupported Redis resource options: {names}")
|
|
288
|
+
if not self.config.options.get("url") and not self.config.options.get("url_env"):
|
|
289
|
+
raise RedisConfigError("Redis resource requires url_env")
|
|
290
|
+
for option in ("timeout", "socket_timeout", "socket_connect_timeout"):
|
|
291
|
+
if option in self.config.options and float(self.config.options[option]) <= 0:
|
|
292
|
+
raise RedisConfigError(f"Redis {option} must be positive")
|
|
293
|
+
|
|
294
|
+
def _safe_error(self, exc: Exception) -> str:
|
|
295
|
+
message = str(exc)
|
|
296
|
+
try:
|
|
297
|
+
url = str(self.config.resolved_options().get("url", ""))
|
|
298
|
+
except DataConfigurationError:
|
|
299
|
+
url = str(self.config.options.get("url", ""))
|
|
300
|
+
sensitive_values = {url}
|
|
301
|
+
try:
|
|
302
|
+
parsed = urlsplit(url)
|
|
303
|
+
if parsed.username:
|
|
304
|
+
sensitive_values.add(parsed.username)
|
|
305
|
+
if parsed.password:
|
|
306
|
+
sensitive_values.add(parsed.password)
|
|
307
|
+
except Exception: # pragma: no cover
|
|
308
|
+
pass
|
|
309
|
+
for value in sorted((item for item in sensitive_values if item), key=len, reverse=True):
|
|
310
|
+
message = message.replace(value, "***")
|
|
311
|
+
return message
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class RedisDataFactory:
|
|
315
|
+
resource_type = "redis"
|
|
316
|
+
|
|
317
|
+
def __init__(self, *, client_factory: Callable[[DataResourceConfig], Any] | None = None) -> None:
|
|
318
|
+
self._client_factory = client_factory
|
|
319
|
+
|
|
320
|
+
def capabilities(self, config: DataResourceConfig) -> set[DataCapability]:
|
|
321
|
+
native = {DataCapability.NATIVE_CLIENT} if bool(config.options.get("native_client")) else set()
|
|
322
|
+
return {
|
|
323
|
+
DataCapability.KEY_VALUE,
|
|
324
|
+
DataCapability.CACHE,
|
|
325
|
+
DataCapability.LOCK,
|
|
326
|
+
DataCapability.STREAM,
|
|
327
|
+
DataCapability.HEALTHCHECK,
|
|
328
|
+
} | native
|
|
329
|
+
|
|
330
|
+
def create(self, config: DataResourceConfig) -> RedisDataAdapter:
|
|
331
|
+
return RedisDataAdapter(config, client_factory=self._client_factory)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _default_redis_client(config: DataResourceConfig):
|
|
335
|
+
try:
|
|
336
|
+
redis_module = importlib.import_module("redis")
|
|
337
|
+
except ImportError as exc:
|
|
338
|
+
raise RedisClientMissingError("redis package is not installed; install muscles-data-redis") from exc
|
|
339
|
+
redis_cls = getattr(redis_module, "Redis", None)
|
|
340
|
+
from_url = getattr(redis_cls, "from_url", None) if redis_cls is not None else None
|
|
341
|
+
if from_url is None:
|
|
342
|
+
from_url = getattr(redis_module, "from_url", None)
|
|
343
|
+
if from_url is None:
|
|
344
|
+
raise RedisClientMissingError("redis package does not expose Redis.from_url")
|
|
345
|
+
|
|
346
|
+
options = config.resolved_options()
|
|
347
|
+
kwargs: dict[str, Any] = {}
|
|
348
|
+
kwargs["decode_responses"] = bool(options.get("decode_responses", False))
|
|
349
|
+
timeout = options.get("timeout")
|
|
350
|
+
if timeout is not None:
|
|
351
|
+
kwargs["socket_timeout"] = float(timeout)
|
|
352
|
+
kwargs["socket_connect_timeout"] = float(timeout)
|
|
353
|
+
for option in ("socket_timeout", "socket_connect_timeout"):
|
|
354
|
+
if option in options:
|
|
355
|
+
kwargs[option] = float(options[option])
|
|
356
|
+
return from_url(str(options["url"]), **kwargs)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ttl_ms(value: float, label: str) -> int:
|
|
360
|
+
seconds = float(value)
|
|
361
|
+
if seconds <= 0:
|
|
362
|
+
raise RedisConfigError(f"{label} must be positive")
|
|
363
|
+
return max(1, int(seconds * 1000))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _name(value: str, label: str) -> str:
|
|
367
|
+
normalized = str(value).strip()
|
|
368
|
+
if not normalized:
|
|
369
|
+
raise RedisConfigError(f"{label} must not be empty")
|
|
370
|
+
return normalized.strip(":")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _bytes_or_none(value: Any) -> bytes | None:
|
|
374
|
+
if value is None:
|
|
375
|
+
return None
|
|
376
|
+
if isinstance(value, bytes):
|
|
377
|
+
return value
|
|
378
|
+
if isinstance(value, bytearray):
|
|
379
|
+
return bytes(value)
|
|
380
|
+
if isinstance(value, memoryview):
|
|
381
|
+
return value.tobytes()
|
|
382
|
+
return str(value).encode("utf-8")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _text(value: Any) -> str:
|
|
386
|
+
if isinstance(value, bytes):
|
|
387
|
+
return value.decode("utf-8")
|
|
388
|
+
return str(value)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _stream_fields(message: Mapping[str, Any]) -> dict[str, Any]:
|
|
392
|
+
return {
|
|
393
|
+
"_schema": "muscles.data.message.v1",
|
|
394
|
+
"_payload": json.dumps(dict(message), ensure_ascii=False, sort_keys=True),
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _read_fields(fields: Mapping[Any, Any]) -> dict[str, Any]:
|
|
399
|
+
return {_text(key): _decode_stream_value(value) for key, value in dict(fields).items()}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _decode_stream_value(value: Any) -> Any:
|
|
403
|
+
if isinstance(value, bytes):
|
|
404
|
+
return value.decode("utf-8")
|
|
405
|
+
return value
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _decode_envelope(fields: Mapping[Any, Any]) -> dict[str, Any]:
|
|
409
|
+
normalized = _read_fields(fields)
|
|
410
|
+
try:
|
|
411
|
+
payload = json.loads(str(normalized.get("_payload", "{}")))
|
|
412
|
+
except (TypeError, ValueError) as exc:
|
|
413
|
+
raise RedisConnectionError("Redis stream message has invalid envelope payload") from exc
|
|
414
|
+
if not isinstance(payload, dict):
|
|
415
|
+
raise RedisConnectionError("Redis stream message envelope payload must be an object")
|
|
416
|
+
return {
|
|
417
|
+
"fields": payload,
|
|
418
|
+
"envelope": {"version": 1, "schema": normalized.get("_schema")},
|
|
419
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-data-redis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redis KeyValuePort, LockPort and StreamPort adapter for muscles-data
|
|
5
|
+
Author-email: "Denis B." <denis@butko.info>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: muscles-data<1.0.0,>=0.1.0
|
|
9
|
+
Requires-Dist: redis<9.0,>=5.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
|
|
12
|
+
|
|
13
|
+
# muscles-data-redis
|
|
14
|
+
|
|
15
|
+
Redis adapter package for `muscles-data`.
|
|
16
|
+
|
|
17
|
+
This package is intentionally separate from `muscles-data`: the core package
|
|
18
|
+
owns typed ports, resource runtime and diagnostics, while this package owns
|
|
19
|
+
Redis-backed `KeyValuePort`, `LockPort` and `StreamPort` implementations.
|
|
20
|
+
|
|
21
|
+
## Related packages
|
|
22
|
+
|
|
23
|
+
- Core runtime and port contracts:
|
|
24
|
+
[`muscles-data`](https://github.com/butkoden/muscles-data)
|
|
25
|
+
- Elasticsearch search adapter:
|
|
26
|
+
[`muscles-data-elasticsearch`](https://github.com/butkoden/muscles-data-elasticsearch)
|
|
27
|
+
- OpenSearch search adapter:
|
|
28
|
+
[`muscles-data-opensearch`](https://github.com/butkoden/muscles-data-opensearch)
|
|
29
|
+
- Qdrant vector adapter:
|
|
30
|
+
[`muscles-data-qdrant`](https://github.com/butkoden/muscles-data-qdrant)
|
|
31
|
+
- MongoDB document-store adapter:
|
|
32
|
+
[`muscles-data-mongodb`](https://github.com/butkoden/muscles-data-mongodb)
|
|
33
|
+
- S3 object-store adapter:
|
|
34
|
+
[`muscles-data-s3`](https://github.com/butkoden/muscles-data-s3)
|
|
35
|
+
- SQLAlchemy direct SQL resource adapter:
|
|
36
|
+
[`muscles-data-sqlalchemy`](https://github.com/butkoden/muscles-data-sqlalchemy)
|
|
37
|
+
- Executable example:
|
|
38
|
+
[`example_data_redis_1`](https://github.com/butkoden/muscular-example/tree/master/example_data_redis_1)
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Register the factory in the project composition root:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
46
|
+
from muscles_data.ports import KeyValuePort
|
|
47
|
+
from muscles_data.runtime import DataRuntime
|
|
48
|
+
from muscles_data_redis import RedisDataFactory
|
|
49
|
+
|
|
50
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
51
|
+
catalog.register(RedisDataFactory())
|
|
52
|
+
|
|
53
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
54
|
+
cache = runtime.require_port("cache.redis", KeyValuePort)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Resource config stays in the project:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
data:
|
|
61
|
+
resources:
|
|
62
|
+
cache.redis:
|
|
63
|
+
type: redis
|
|
64
|
+
url_env: REDIS_URL
|
|
65
|
+
namespace: app
|
|
66
|
+
timeout: 3
|
|
67
|
+
stream_group: workers
|
|
68
|
+
consumer: index-worker
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The adapter creates the Redis client lazily by key-value, lock, stream, explicit
|
|
72
|
+
native access or `data.doctor` operations. Application code should use
|
|
73
|
+
`KeyValuePort`, `LockPort` and `StreamPort`; direct client access is only an
|
|
74
|
+
advanced escape hatch with `native_client: true`.
|
|
75
|
+
|
|
76
|
+
Streams use `XADD`, `XREADGROUP` and `XACK`. The configured consumer group is
|
|
77
|
+
created on first read and messages use the versioned
|
|
78
|
+
`muscles.data.message.v1` envelope. Retry and dead-letter decisions stay with
|
|
79
|
+
the application worker.
|
|
80
|
+
|
|
81
|
+
See `muscular-example/example_data_redis_1` for an executable example.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/muscles_data_redis/__init__.py
|
|
4
|
+
src/muscles_data_redis/adapter.py
|
|
5
|
+
src/muscles_data_redis.egg-info/PKG-INFO
|
|
6
|
+
src/muscles_data_redis.egg-info/SOURCES.txt
|
|
7
|
+
src/muscles_data_redis.egg-info/dependency_links.txt
|
|
8
|
+
src/muscles_data_redis.egg-info/requires.txt
|
|
9
|
+
src/muscles_data_redis.egg-info/top_level.txt
|
|
10
|
+
tests/test_integration.py
|
|
11
|
+
tests/test_redis_adapter.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
muscles_data_redis
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
9
|
+
from muscles_data.config import DataConfig
|
|
10
|
+
from muscles_data.ports import KeyValuePort, LockPort, StreamPort
|
|
11
|
+
from muscles_data.runtime import DataRuntime
|
|
12
|
+
|
|
13
|
+
from muscles_data_redis import RedisDataFactory
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
pytestmark = [
|
|
17
|
+
pytest.mark.integration,
|
|
18
|
+
pytest.mark.skipif(not os.getenv("MUSCLES_DATA_INTEGRATION"), reason="backend integration is disabled"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_redis_real_kv_lock_stream_lifecycle():
|
|
23
|
+
namespace = f"muscles-data-it-{uuid4().hex[:12]}"
|
|
24
|
+
config = DataConfig.from_raw(
|
|
25
|
+
{
|
|
26
|
+
"data": {
|
|
27
|
+
"resources": {
|
|
28
|
+
"cache.redis": {
|
|
29
|
+
"type": "redis",
|
|
30
|
+
"url_env": "REDIS_URL",
|
|
31
|
+
"namespace": namespace,
|
|
32
|
+
"stream_group": f"group-{namespace}",
|
|
33
|
+
"consumer": f"consumer-{namespace}",
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
40
|
+
catalog.register(RedisDataFactory())
|
|
41
|
+
runtime = DataRuntime(config=config, catalog=catalog)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
key_value = runtime.require_port("cache.redis", KeyValuePort)
|
|
45
|
+
lock = runtime.require_port("cache.redis", LockPort)
|
|
46
|
+
stream = runtime.require_port("cache.redis", StreamPort)
|
|
47
|
+
contracts = pytest.importorskip("muscles_data.contracts")
|
|
48
|
+
for name, port in (
|
|
49
|
+
("assert_key_value_contract", key_value),
|
|
50
|
+
("assert_lock_contract", lock),
|
|
51
|
+
("assert_stream_contract", stream),
|
|
52
|
+
):
|
|
53
|
+
contract = getattr(contracts, name, None)
|
|
54
|
+
if contract is not None:
|
|
55
|
+
contract(lambda port=port: port)
|
|
56
|
+
assert key_value.set("ttl", b"short", ttl_seconds=0.1).written == 1
|
|
57
|
+
time.sleep(0.2)
|
|
58
|
+
assert key_value.exists("ttl") is False
|
|
59
|
+
assert runtime.doctor()["status"] == "ok"
|
|
60
|
+
finally:
|
|
61
|
+
runtime.close()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from muscles_data.catalog import DataAdapterCatalog
|
|
7
|
+
from muscles_data.config import DataConfig
|
|
8
|
+
from muscles_data.models import DataCapability, LockHandle
|
|
9
|
+
from muscles_data.ports import KeyValuePort, LockPort, StreamPort
|
|
10
|
+
from muscles_data.runtime import DataRuntime
|
|
11
|
+
|
|
12
|
+
from muscles_data_redis import RedisClientMissingError, RedisConfigError, RedisConnectionError, RedisDataFactory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeRedisClient:
|
|
16
|
+
def __init__(self, *, fail_ping: bool = False) -> None:
|
|
17
|
+
self.fail_ping = fail_ping
|
|
18
|
+
self.values: dict[str, Any] = {}
|
|
19
|
+
self.sets: list[dict[str, Any]] = []
|
|
20
|
+
self.deletes: list[tuple[str, ...]] = []
|
|
21
|
+
self.streams: dict[str, list[tuple[str, dict[str, Any]]]] = {}
|
|
22
|
+
self.xacks: list[dict[str, Any]] = []
|
|
23
|
+
self.xgroups: list[dict[str, Any]] = []
|
|
24
|
+
self.pings = 0
|
|
25
|
+
self.closed = False
|
|
26
|
+
|
|
27
|
+
def set(self, name: str, value, **kwargs):
|
|
28
|
+
self.sets.append({"name": name, "value": value, **kwargs})
|
|
29
|
+
if kwargs.get("nx") and name in self.values:
|
|
30
|
+
return False
|
|
31
|
+
self.values[name] = value
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def get(self, name: str):
|
|
35
|
+
return self.values.get(name)
|
|
36
|
+
|
|
37
|
+
def delete(self, *names: str) -> int:
|
|
38
|
+
self.deletes.append(tuple(names))
|
|
39
|
+
deleted = 0
|
|
40
|
+
for name in names:
|
|
41
|
+
deleted += 1 if self.values.pop(name, None) is not None else 0
|
|
42
|
+
return deleted
|
|
43
|
+
|
|
44
|
+
def exists(self, *names: str) -> int:
|
|
45
|
+
return sum(1 for name in names if name in self.values)
|
|
46
|
+
|
|
47
|
+
def eval(self, _script: str, _numkeys: int, key: str, expected_token: str):
|
|
48
|
+
if self.values.get(key) != expected_token:
|
|
49
|
+
return 0
|
|
50
|
+
self.values.pop(key, None)
|
|
51
|
+
return 1
|
|
52
|
+
|
|
53
|
+
def xadd(self, name: str, fields: dict[str, Any]):
|
|
54
|
+
stream = self.streams.setdefault(name, [])
|
|
55
|
+
message_id = f"{len(stream) + 1}-0"
|
|
56
|
+
stream.append((message_id, dict(fields)))
|
|
57
|
+
return message_id
|
|
58
|
+
|
|
59
|
+
def xread(self, streams: dict[str, str], count: int | None = None, block: int | None = None):
|
|
60
|
+
del block
|
|
61
|
+
output = []
|
|
62
|
+
for name, cursor in streams.items():
|
|
63
|
+
messages = [
|
|
64
|
+
(message_id, fields)
|
|
65
|
+
for message_id, fields in self.streams.get(name, [])
|
|
66
|
+
if cursor in {"0", "0-0", ">"} or message_id > cursor
|
|
67
|
+
]
|
|
68
|
+
output.append((name, messages[:count]))
|
|
69
|
+
return output
|
|
70
|
+
|
|
71
|
+
def xgroup_create(self, name: str, groupname: str, id: str = "0-0", mkstream: bool = False):
|
|
72
|
+
self.xgroups.append({"name": name, "groupname": groupname, "id": id, "mkstream": mkstream})
|
|
73
|
+
|
|
74
|
+
def xreadgroup(self, groupname: str, consumername: str, streams: dict[str, str], count: int | None = None, block: int | None = None):
|
|
75
|
+
del groupname, consumername, block
|
|
76
|
+
return self.xread(streams, count=count)
|
|
77
|
+
|
|
78
|
+
def xack(self, name: str, groupname: str, *ids: str) -> int:
|
|
79
|
+
self.xacks.append({"name": name, "groupname": groupname, "ids": ids})
|
|
80
|
+
known = {message_id for message_id, _fields in self.streams.get(name, [])}
|
|
81
|
+
return sum(1 for message_id in ids if message_id in known)
|
|
82
|
+
|
|
83
|
+
def ping(self) -> bool:
|
|
84
|
+
self.pings += 1
|
|
85
|
+
if self.fail_ping:
|
|
86
|
+
raise TimeoutError("redis password=redis-secret timed out")
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def close(self) -> None:
|
|
90
|
+
self.closed = True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _config() -> dict:
|
|
94
|
+
return {
|
|
95
|
+
"data": {
|
|
96
|
+
"resources": {
|
|
97
|
+
"cache.redis": {
|
|
98
|
+
"type": "redis",
|
|
99
|
+
"url": "redis://:redis-secret@localhost:6379/0",
|
|
100
|
+
"namespace": "app",
|
|
101
|
+
"stream_group": "workers",
|
|
102
|
+
"native_client": True,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _runtime(client: FakeRedisClient | None):
|
|
110
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
111
|
+
catalog.register(RedisDataFactory(client_factory=lambda _config: client))
|
|
112
|
+
return DataRuntime(config=DataConfig.from_raw(_config()), catalog=catalog)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_redis_external_adapter_maps_key_value_lock_stream_and_native_access():
|
|
116
|
+
client = FakeRedisClient()
|
|
117
|
+
runtime = _runtime(client)
|
|
118
|
+
|
|
119
|
+
listed = runtime.list_resources()[0]
|
|
120
|
+
assert listed["type"] == "redis"
|
|
121
|
+
assert {"key_value", "cache", "lock", "stream"} <= set(listed["capabilities"])
|
|
122
|
+
assert listed["initialized"] is False
|
|
123
|
+
|
|
124
|
+
cache = runtime.require_port("cache.redis", KeyValuePort)
|
|
125
|
+
assert cache.set("cursor", b"cursor-1", ttl_seconds=2.5).written == 1
|
|
126
|
+
assert client.sets[0]["name"] == "app:cursor"
|
|
127
|
+
assert client.sets[0]["px"] == 2500
|
|
128
|
+
assert cache.get("cursor") == b"cursor-1"
|
|
129
|
+
assert cache.exists("cursor") is True
|
|
130
|
+
|
|
131
|
+
lock = runtime.require_port("cache.redis", LockPort)
|
|
132
|
+
handle = lock.acquire_lock("daily-job", ttl_seconds=5)
|
|
133
|
+
assert isinstance(handle, LockHandle)
|
|
134
|
+
assert lock.release_lock(handle).deleted == 1
|
|
135
|
+
|
|
136
|
+
stream = runtime.require_port("cache.redis", StreamPort)
|
|
137
|
+
assert stream.publish("events", {"kind": "created"}).written == 1
|
|
138
|
+
read = stream.read("events", limit=10)
|
|
139
|
+
assert read.messages[0]["fields"] == {"kind": "created"}
|
|
140
|
+
assert read.messages[0]["envelope"]["version"] == 1
|
|
141
|
+
assert stream.ack("events", "1-0").matched == 1
|
|
142
|
+
assert client.xgroups[0]["groupname"] == "workers"
|
|
143
|
+
|
|
144
|
+
native = runtime.require_resource("cache.redis", DataCapability.NATIVE_CLIENT).native_client()
|
|
145
|
+
assert native is client
|
|
146
|
+
assert runtime.doctor()["status"] == "ok"
|
|
147
|
+
assert client.pings == 1
|
|
148
|
+
assert runtime.close()["status"] == "ok"
|
|
149
|
+
assert client.closed is True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_redis_external_adapter_reports_safe_failures():
|
|
153
|
+
with pytest.raises(RedisClientMissingError):
|
|
154
|
+
_runtime(None).require_port("cache.redis", KeyValuePort).get("cursor")
|
|
155
|
+
|
|
156
|
+
failing = _runtime(FakeRedisClient(fail_ping=True)).doctor()
|
|
157
|
+
assert failing["status"] == "failed"
|
|
158
|
+
assert "redis-secret" not in repr(failing)
|
|
159
|
+
|
|
160
|
+
bad_client = FakeRedisClient()
|
|
161
|
+
bad_client.get = lambda _name: (_ for _ in ()).throw(RuntimeError("redis://:redis-secret@localhost unavailable"))
|
|
162
|
+
with pytest.raises(RedisConnectionError):
|
|
163
|
+
_runtime(bad_client).require_port("cache.redis", KeyValuePort).get("cursor")
|
|
164
|
+
|
|
165
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
166
|
+
catalog.register(RedisDataFactory(client_factory=lambda config: config.resolved_options()))
|
|
167
|
+
unsupported = DataRuntime(
|
|
168
|
+
config=DataConfig.from_raw({"data": {"resources": {"cache.redis": {"type": "redis", "url": "redis://localhost", "unsafe": True}}}}),
|
|
169
|
+
catalog=catalog,
|
|
170
|
+
)
|
|
171
|
+
with pytest.raises(RedisConfigError, match="Unsupported Redis resource options"):
|
|
172
|
+
unsupported.require_port("cache.redis", KeyValuePort).get("cursor")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_redis_stream_uses_consumer_groups_and_resolves_url_env(monkeypatch):
|
|
176
|
+
monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0")
|
|
177
|
+
client = FakeRedisClient()
|
|
178
|
+
captured: list[dict] = []
|
|
179
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
180
|
+
catalog.register(
|
|
181
|
+
RedisDataFactory(client_factory=lambda config: captured.append(config.resolved_options()) or client)
|
|
182
|
+
)
|
|
183
|
+
runtime = DataRuntime(
|
|
184
|
+
config=DataConfig.from_raw(
|
|
185
|
+
{
|
|
186
|
+
"data": {
|
|
187
|
+
"resources": {
|
|
188
|
+
"stream.redis": {
|
|
189
|
+
"type": "redis",
|
|
190
|
+
"url_env": "REDIS_URL",
|
|
191
|
+
"namespace": "assetforge",
|
|
192
|
+
"stream_group": "workers",
|
|
193
|
+
"consumer": "worker-1",
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
),
|
|
199
|
+
catalog=catalog,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
stream = runtime.require_port("stream.redis", StreamPort)
|
|
203
|
+
stream.publish("index", {"job": "reindex"})
|
|
204
|
+
stream.read("index")
|
|
205
|
+
|
|
206
|
+
assert captured[0]["url"] == "redis://localhost:6379/0"
|
|
207
|
+
assert client.xgroups[0]["mkstream"] is True
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_redis_doctor_reports_configuration_error_for_missing_url_env():
|
|
211
|
+
catalog = DataAdapterCatalog.with_defaults()
|
|
212
|
+
catalog.register(RedisDataFactory(client_factory=lambda config: config.resolved_options()))
|
|
213
|
+
runtime = DataRuntime(
|
|
214
|
+
config=DataConfig.from_raw(
|
|
215
|
+
{"data": {"resources": {"stream.redis": {"type": "redis", "url_env": "MISSING_REDIS_URL"}}}}
|
|
216
|
+
),
|
|
217
|
+
catalog=catalog,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
doctor = runtime.doctor()
|
|
221
|
+
check = doctor["checks"][0]
|
|
222
|
+
assert check["status"] == "failed"
|
|
223
|
+
assert check["code"] == "configuration_error"
|