cledar-sdk 2.0.2__py3-none-any.whl → 2.1.0__py3-none-any.whl
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.
- cledar/__init__.py +1 -0
- cledar/kafka/README.md +239 -0
- cledar/kafka/__init__.py +42 -0
- cledar/kafka/clients/base.py +117 -0
- cledar/kafka/clients/consumer.py +138 -0
- cledar/kafka/clients/producer.py +97 -0
- cledar/kafka/config/schemas.py +262 -0
- cledar/kafka/exceptions.py +17 -0
- cledar/kafka/handlers/dead_letter.py +88 -0
- cledar/kafka/handlers/parser.py +83 -0
- cledar/kafka/logger.py +5 -0
- cledar/kafka/models/input.py +17 -0
- cledar/kafka/models/message.py +14 -0
- cledar/kafka/models/output.py +12 -0
- cledar/kafka/tests/.env.test.kafka +3 -0
- cledar/kafka/tests/README.md +216 -0
- cledar/kafka/tests/conftest.py +104 -0
- cledar/kafka/tests/integration/__init__.py +1 -0
- cledar/kafka/tests/integration/conftest.py +78 -0
- cledar/kafka/tests/integration/helpers.py +47 -0
- cledar/kafka/tests/integration/test_consumer_integration.py +375 -0
- cledar/kafka/tests/integration/test_integration.py +394 -0
- cledar/kafka/tests/integration/test_producer_consumer_interaction.py +388 -0
- cledar/kafka/tests/integration/test_producer_integration.py +217 -0
- cledar/kafka/tests/unit/__init__.py +1 -0
- cledar/kafka/tests/unit/test_base_kafka_client.py +391 -0
- cledar/kafka/tests/unit/test_config_validation.py +609 -0
- cledar/kafka/tests/unit/test_dead_letter_handler.py +443 -0
- cledar/kafka/tests/unit/test_error_handling.py +674 -0
- cledar/kafka/tests/unit/test_input_parser.py +310 -0
- cledar/kafka/tests/unit/test_input_parser_comprehensive.py +489 -0
- cledar/kafka/tests/unit/test_utils.py +25 -0
- cledar/kafka/tests/unit/test_utils_comprehensive.py +408 -0
- cledar/kafka/utils/callbacks.py +28 -0
- cledar/kafka/utils/messages.py +39 -0
- cledar/kafka/utils/topics.py +15 -0
- cledar/kserve/README.md +352 -0
- cledar/kserve/__init__.py +5 -0
- cledar/kserve/tests/__init__.py +0 -0
- cledar/kserve/tests/test_utils.py +64 -0
- cledar/kserve/utils.py +30 -0
- cledar/logging/README.md +53 -0
- cledar/logging/__init__.py +5 -0
- cledar/logging/tests/test_universal_plaintext_formatter.py +249 -0
- cledar/logging/universal_plaintext_formatter.py +99 -0
- cledar/monitoring/README.md +71 -0
- cledar/monitoring/__init__.py +5 -0
- cledar/monitoring/monitoring_server.py +156 -0
- cledar/monitoring/tests/integration/test_monitoring_server_int.py +162 -0
- cledar/monitoring/tests/test_monitoring_server.py +59 -0
- cledar/nonce/README.md +99 -0
- cledar/nonce/__init__.py +5 -0
- cledar/nonce/nonce_service.py +62 -0
- cledar/nonce/tests/__init__.py +0 -0
- cledar/nonce/tests/test_nonce_service.py +136 -0
- cledar/redis/README.md +536 -0
- cledar/redis/__init__.py +17 -0
- cledar/redis/async_example.py +112 -0
- cledar/redis/example.py +67 -0
- cledar/redis/exceptions.py +25 -0
- cledar/redis/logger.py +5 -0
- cledar/redis/model.py +14 -0
- cledar/redis/redis.py +764 -0
- cledar/redis/redis_config_store.py +333 -0
- cledar/redis/tests/test_async_integration_redis.py +158 -0
- cledar/redis/tests/test_async_redis_service.py +380 -0
- cledar/redis/tests/test_integration_redis.py +119 -0
- cledar/redis/tests/test_redis_service.py +319 -0
- cledar/storage/README.md +529 -0
- cledar/storage/__init__.py +6 -0
- cledar/storage/constants.py +5 -0
- cledar/storage/exceptions.py +79 -0
- cledar/storage/models.py +41 -0
- cledar/storage/object_storage.py +1274 -0
- cledar/storage/tests/conftest.py +18 -0
- cledar/storage/tests/test_abfs.py +164 -0
- cledar/storage/tests/test_integration_filesystem.py +359 -0
- cledar/storage/tests/test_integration_s3.py +453 -0
- cledar/storage/tests/test_local.py +384 -0
- cledar/storage/tests/test_s3.py +521 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/METADATA +1 -1
- cledar_sdk-2.1.0.dist-info/RECORD +84 -0
- cledar_sdk-2.0.2.dist-info/RECORD +0 -4
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/WHEEL +0 -0
- {cledar_sdk-2.0.2.dist-info → cledar_sdk-2.1.0.dist-info}/licenses/LICENSE +0 -0
cledar/redis/redis.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"""Redis service implementations for the Cledar SDK."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
import redis
|
|
11
|
+
import redis.asyncio as aioredis
|
|
12
|
+
from pydantic import BaseModel, ValidationError
|
|
13
|
+
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
RedisConnectionError,
|
|
16
|
+
RedisDeserializationError,
|
|
17
|
+
RedisOperationError,
|
|
18
|
+
RedisSerializationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("redis_service")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CustomEncoder(json.JSONEncoder):
|
|
25
|
+
"""Custom JSON encoder that can handle Enum objects and datetime objects."""
|
|
26
|
+
|
|
27
|
+
def default(self, o: Any) -> Any:
|
|
28
|
+
"""Process objects for JSON encoding.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
o: The object to encode.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Any: The encoded object.
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(o, Enum):
|
|
38
|
+
return o.name.lower()
|
|
39
|
+
if isinstance(o, datetime):
|
|
40
|
+
return o.isoformat()
|
|
41
|
+
return super().default(o)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T", bound=BaseModel)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FailedValue:
|
|
49
|
+
"""Represents a failed Redis operation for a specific key."""
|
|
50
|
+
|
|
51
|
+
key: str
|
|
52
|
+
error: Exception
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class RedisServiceConfig:
|
|
57
|
+
"""Configuration for Redis services."""
|
|
58
|
+
|
|
59
|
+
redis_host: str
|
|
60
|
+
redis_port: int
|
|
61
|
+
redis_db: int = 0
|
|
62
|
+
redis_password: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RedisService:
|
|
66
|
+
"""Synchronous Redis service with Pydantic model support."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, config: RedisServiceConfig):
|
|
69
|
+
"""Initialize the synchronous Redis service.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
config: The service configuration.
|
|
73
|
+
|
|
74
|
+
"""
|
|
75
|
+
self.config = config
|
|
76
|
+
self._client: redis.Redis
|
|
77
|
+
self.connect()
|
|
78
|
+
|
|
79
|
+
def connect(self) -> None:
|
|
80
|
+
"""Establish a connection to Redis.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
RedisConnectionError: If the connection fails.
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
self._client = redis.Redis(
|
|
88
|
+
host=self.config.redis_host,
|
|
89
|
+
port=self.config.redis_port,
|
|
90
|
+
db=self.config.redis_db,
|
|
91
|
+
password=self.config.redis_password,
|
|
92
|
+
decode_responses=True,
|
|
93
|
+
)
|
|
94
|
+
logger.info(
|
|
95
|
+
"Redis client initialized.",
|
|
96
|
+
extra={
|
|
97
|
+
"host": self.config.redis_host,
|
|
98
|
+
"port": self.config.redis_port,
|
|
99
|
+
"db": self.config.redis_db,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
self._client.ping()
|
|
103
|
+
logger.info(
|
|
104
|
+
"Redis client pinged successfully.",
|
|
105
|
+
extra={"host": self.config.redis_host},
|
|
106
|
+
)
|
|
107
|
+
except redis.ConnectionError as exc:
|
|
108
|
+
logger.exception("Failed to initialize Redis client.")
|
|
109
|
+
raise RedisConnectionError("Could not initialize Redis client") from exc
|
|
110
|
+
|
|
111
|
+
def is_alive(self) -> bool:
|
|
112
|
+
"""Check if the Redis connection is alive.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
bool: True if connection is alive, False otherwise.
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
return bool(self._client.ping())
|
|
120
|
+
except redis.ConnectionError:
|
|
121
|
+
logger.exception(
|
|
122
|
+
"Redis connection error during health check. Can't ping Redis host %s",
|
|
123
|
+
self.config.redis_host,
|
|
124
|
+
)
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
def _prepare_for_serialization(self, value: Any) -> Any:
|
|
128
|
+
"""Process data structures for serialization.
|
|
129
|
+
|
|
130
|
+
Recursively process data structures, converting BaseModel instances to
|
|
131
|
+
serializable dicts.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
value: The value to process.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Any: The processed value.
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(value, BaseModel):
|
|
141
|
+
return value.model_dump()
|
|
142
|
+
if isinstance(value, list):
|
|
143
|
+
return [self._prepare_for_serialization(item) for item in value]
|
|
144
|
+
if isinstance(value, dict):
|
|
145
|
+
return {k: self._prepare_for_serialization(v) for k, v in value.items()}
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
def set(self, key: str, value: Any) -> bool:
|
|
149
|
+
"""Set a value in Redis.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
key: The key to set.
|
|
153
|
+
value: The value to set.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
bool: True if successful, False otherwise.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If the key is not a string.
|
|
160
|
+
RedisSerializationError: If serialization fails.
|
|
161
|
+
RedisConnectionError: If the connection fails.
|
|
162
|
+
RedisOperationError: If the operation fails.
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
if not isinstance(key, str):
|
|
166
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
167
|
+
if value is None:
|
|
168
|
+
logger.debug("Value is none", extra={"key": key})
|
|
169
|
+
try:
|
|
170
|
+
processed_value = self._prepare_for_serialization(value)
|
|
171
|
+
if isinstance(processed_value, (dict, list)):
|
|
172
|
+
try:
|
|
173
|
+
final_value = json.dumps(processed_value, cls=CustomEncoder)
|
|
174
|
+
|
|
175
|
+
except (TypeError, ValueError) as exc:
|
|
176
|
+
logger.exception(
|
|
177
|
+
"Serialization error before setting Redis key.",
|
|
178
|
+
extra={"key": key},
|
|
179
|
+
)
|
|
180
|
+
raise RedisSerializationError(
|
|
181
|
+
"Failed to serialize value for Redis"
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
else:
|
|
185
|
+
final_value = processed_value
|
|
186
|
+
return bool(self._client.set(key, final_value))
|
|
187
|
+
|
|
188
|
+
except redis.ConnectionError as exc:
|
|
189
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
190
|
+
raise RedisConnectionError(
|
|
191
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
192
|
+
) from exc
|
|
193
|
+
|
|
194
|
+
except redis.RedisError as exc:
|
|
195
|
+
logger.exception("Error setting Redis key.", extra={"key": key})
|
|
196
|
+
raise RedisOperationError(f"Failed to set key '{key}'") from exc
|
|
197
|
+
|
|
198
|
+
def get(self, key: str, model: type[T]) -> T | None:
|
|
199
|
+
"""Get a value from Redis and validate it against a model.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
key: The key to fetch.
|
|
203
|
+
model: The Pydantic model to validate against.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
T | None: The validated model or None if key does not exist.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
ValueError: If the key is not a string.
|
|
210
|
+
RedisDeserializationError: If decoding or validation fails.
|
|
211
|
+
RedisConnectionError: If the connection fails.
|
|
212
|
+
RedisOperationError: If the operation fails.
|
|
213
|
+
|
|
214
|
+
"""
|
|
215
|
+
if not isinstance(key, str):
|
|
216
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
value = self._client.get(key)
|
|
220
|
+
if value is None:
|
|
221
|
+
logger.debug("Value is none", extra={"key": key})
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
return model.model_validate(json.loads(str(value)))
|
|
225
|
+
|
|
226
|
+
except json.JSONDecodeError as exc:
|
|
227
|
+
logger.exception("JSON Decode error.", extra={"key": key})
|
|
228
|
+
raise RedisDeserializationError(
|
|
229
|
+
f"Failed to decode JSON for key '{key}'"
|
|
230
|
+
) from exc
|
|
231
|
+
|
|
232
|
+
except ValidationError as exc:
|
|
233
|
+
logger.exception(
|
|
234
|
+
"Validation error.", extra={"key": key, "model": model}
|
|
235
|
+
)
|
|
236
|
+
raise RedisDeserializationError(
|
|
237
|
+
f"Validation failed for key '{key}' and model '{model.__name__}'"
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
except redis.ConnectionError as exc:
|
|
241
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
242
|
+
raise RedisConnectionError(
|
|
243
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
244
|
+
) from exc
|
|
245
|
+
|
|
246
|
+
except redis.RedisError as exc:
|
|
247
|
+
logger.exception("Error getting Redis key.", extra={"key": key})
|
|
248
|
+
raise RedisOperationError(f"Failed to get key '{key}'") from exc
|
|
249
|
+
|
|
250
|
+
def get_raw(self, key: str) -> Any | None:
|
|
251
|
+
"""Get a raw value from Redis.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
key: The key to fetch.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Any | None: The raw value or None if key does not exist.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ValueError: If the key is not a string.
|
|
261
|
+
RedisConnectionError: If the connection fails.
|
|
262
|
+
RedisOperationError: If the operation fails.
|
|
263
|
+
|
|
264
|
+
"""
|
|
265
|
+
if not isinstance(key, str):
|
|
266
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
value = self._client.get(key)
|
|
270
|
+
if value is None:
|
|
271
|
+
logger.debug("Value is none", extra={"key": key})
|
|
272
|
+
return value
|
|
273
|
+
|
|
274
|
+
except redis.ConnectionError as exc:
|
|
275
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
276
|
+
raise RedisConnectionError(
|
|
277
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
278
|
+
) from exc
|
|
279
|
+
|
|
280
|
+
except redis.RedisError as exc:
|
|
281
|
+
logger.exception("Error getting Redis key.", extra={"key": key})
|
|
282
|
+
raise RedisOperationError(f"Failed to get key '{key}'") from exc
|
|
283
|
+
|
|
284
|
+
def list_keys(self, pattern: str) -> list[str]:
|
|
285
|
+
"""List all keys matching a pattern.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
pattern: The pattern to match.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
list[str]: A list of matching keys.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
ValueError: If the pattern is not a string.
|
|
295
|
+
RedisConnectionError: If the connection fails.
|
|
296
|
+
RedisOperationError: If the operation fails.
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
if not isinstance(pattern, str):
|
|
300
|
+
raise ValueError(f"Pattern must be a string, got {type(pattern)}")
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
keys_result = self._client.keys(pattern)
|
|
304
|
+
return cast(list[str], keys_result)
|
|
305
|
+
|
|
306
|
+
except redis.ConnectionError as exc:
|
|
307
|
+
logger.exception("Redis connection error.", extra={"pattern": pattern})
|
|
308
|
+
raise RedisConnectionError(
|
|
309
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
310
|
+
) from exc
|
|
311
|
+
|
|
312
|
+
except redis.RedisError as exc:
|
|
313
|
+
logger.exception("Error listing Redis keys.", extra={"pattern": pattern})
|
|
314
|
+
raise RedisOperationError(
|
|
315
|
+
f"Failed to list keys for pattern '{pattern}'"
|
|
316
|
+
) from exc
|
|
317
|
+
|
|
318
|
+
def mget(self, keys: list[str], model: type[T]) -> list[T | None | FailedValue]:
|
|
319
|
+
"""Get multiple values from Redis and validate them against a model.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
keys: A list of keys to fetch.
|
|
323
|
+
model: The Pydantic model to validate against.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
list[T | None | FailedValue]: A list of validated models, None for
|
|
327
|
+
missing keys, or FailedValue for items that failed validation.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ValueError: If the keys are not a list.
|
|
331
|
+
RedisConnectionError: If the connection fails.
|
|
332
|
+
RedisOperationError: If the operation fails.
|
|
333
|
+
|
|
334
|
+
"""
|
|
335
|
+
if not isinstance(keys, list):
|
|
336
|
+
raise ValueError(f"Keys must be a list, got {type(keys)}")
|
|
337
|
+
|
|
338
|
+
if not keys:
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
values = cast(list[Any], self._client.mget(keys))
|
|
343
|
+
results: list[T | None | FailedValue] = []
|
|
344
|
+
|
|
345
|
+
for value, key in zip(values, keys, strict=False):
|
|
346
|
+
if value is None:
|
|
347
|
+
results.append(None)
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
validated_data = model.model_validate(json.loads(str(value)))
|
|
352
|
+
results.append(validated_data)
|
|
353
|
+
|
|
354
|
+
except json.JSONDecodeError as exc:
|
|
355
|
+
logger.exception("JSON Decode error.", extra={"key": key})
|
|
356
|
+
results.append(FailedValue(key=key, error=exc))
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
except ValidationError as exc:
|
|
360
|
+
logger.exception(
|
|
361
|
+
"Validation error.",
|
|
362
|
+
extra={"key": key, "model": model.__name__},
|
|
363
|
+
)
|
|
364
|
+
results.append(FailedValue(key=key, error=exc))
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
return results
|
|
368
|
+
|
|
369
|
+
except redis.ConnectionError as exc:
|
|
370
|
+
logger.exception("Redis connection error.", extra={"keys": keys})
|
|
371
|
+
raise RedisConnectionError(
|
|
372
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
373
|
+
) from exc
|
|
374
|
+
|
|
375
|
+
except redis.RedisError as exc:
|
|
376
|
+
logger.exception("Error getting multiple Redis keys.")
|
|
377
|
+
raise RedisOperationError("Failed to mget keys") from exc
|
|
378
|
+
|
|
379
|
+
def delete(self, key: str) -> bool:
|
|
380
|
+
"""Delete a key from Redis.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
key: The key to delete.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
bool: True if the key was deleted, False otherwise.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
ValueError: If the key is not a string.
|
|
390
|
+
RedisConnectionError: If the connection fails.
|
|
391
|
+
RedisOperationError: If the operation fails.
|
|
392
|
+
|
|
393
|
+
"""
|
|
394
|
+
if not isinstance(key, str):
|
|
395
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
result = self._client.delete(key)
|
|
399
|
+
logger.info("Key deleted successfully", extra={"key": key})
|
|
400
|
+
return bool(result)
|
|
401
|
+
|
|
402
|
+
except redis.ConnectionError as exc:
|
|
403
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
404
|
+
raise RedisConnectionError(
|
|
405
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
406
|
+
) from exc
|
|
407
|
+
|
|
408
|
+
except redis.RedisError as exc:
|
|
409
|
+
logger.exception("Error deleting Redis key.", extra={"key": key})
|
|
410
|
+
raise RedisOperationError(f"Failed to delete key '{key}'") from exc
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class AsyncRedisService:
|
|
414
|
+
"""Asynchronous Redis service with async/await support."""
|
|
415
|
+
|
|
416
|
+
def __init__(self, config: RedisServiceConfig):
|
|
417
|
+
"""Initialize the asynchronous Redis service.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
config: The service configuration.
|
|
421
|
+
|
|
422
|
+
"""
|
|
423
|
+
self.config = config
|
|
424
|
+
self._client: aioredis.Redis
|
|
425
|
+
|
|
426
|
+
async def connect(self) -> None:
|
|
427
|
+
"""Establish connection to Redis asynchronously.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
RedisConnectionError: If the connection fails.
|
|
431
|
+
|
|
432
|
+
"""
|
|
433
|
+
try:
|
|
434
|
+
self._client = aioredis.Redis(
|
|
435
|
+
host=self.config.redis_host,
|
|
436
|
+
port=self.config.redis_port,
|
|
437
|
+
db=self.config.redis_db,
|
|
438
|
+
password=self.config.redis_password,
|
|
439
|
+
decode_responses=True,
|
|
440
|
+
)
|
|
441
|
+
logger.info(
|
|
442
|
+
"Async Redis client initialized.",
|
|
443
|
+
extra={
|
|
444
|
+
"host": self.config.redis_host,
|
|
445
|
+
"port": self.config.redis_port,
|
|
446
|
+
"db": self.config.redis_db,
|
|
447
|
+
},
|
|
448
|
+
)
|
|
449
|
+
await self._client.ping()
|
|
450
|
+
logger.info(
|
|
451
|
+
"Async Redis client pinged successfully.",
|
|
452
|
+
extra={"host": self.config.redis_host},
|
|
453
|
+
)
|
|
454
|
+
except aioredis.ConnectionError as exc:
|
|
455
|
+
logger.exception("Failed to initialize async Redis client.")
|
|
456
|
+
raise RedisConnectionError("Could not initialize Redis client") from exc
|
|
457
|
+
|
|
458
|
+
async def close(self) -> None:
|
|
459
|
+
"""Close the Redis connection."""
|
|
460
|
+
await self._client.aclose()
|
|
461
|
+
logger.info("Async Redis client closed.")
|
|
462
|
+
|
|
463
|
+
async def is_alive(self) -> bool:
|
|
464
|
+
"""Check if Redis connection is alive.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
bool: True if the connection is alive, False otherwise.
|
|
468
|
+
|
|
469
|
+
"""
|
|
470
|
+
try:
|
|
471
|
+
return bool(await self._client.ping())
|
|
472
|
+
except aioredis.ConnectionError:
|
|
473
|
+
logger.exception(
|
|
474
|
+
"Redis connection error during health check. Can't ping Redis host %s",
|
|
475
|
+
self.config.redis_host,
|
|
476
|
+
)
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
def _prepare_for_serialization(self, value: Any) -> Any:
|
|
480
|
+
"""Process data structures for serialization.
|
|
481
|
+
|
|
482
|
+
Recursively process data structures, converting BaseModel instances to
|
|
483
|
+
serializable dicts.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
value: The value to process.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Any: The processed value.
|
|
490
|
+
|
|
491
|
+
"""
|
|
492
|
+
if isinstance(value, BaseModel):
|
|
493
|
+
return value.model_dump()
|
|
494
|
+
if isinstance(value, list):
|
|
495
|
+
return [self._prepare_for_serialization(item) for item in value]
|
|
496
|
+
if isinstance(value, dict):
|
|
497
|
+
return {k: self._prepare_for_serialization(v) for k, v in value.items()}
|
|
498
|
+
return value
|
|
499
|
+
|
|
500
|
+
async def set(self, key: str, value: Any) -> bool:
|
|
501
|
+
"""Set a key-value pair in Redis.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
key: The key to set.
|
|
505
|
+
value: The value to set.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
bool: True if successful, False otherwise.
|
|
509
|
+
|
|
510
|
+
Raises:
|
|
511
|
+
ValueError: If the key is not a string.
|
|
512
|
+
RedisSerializationError: If serialization fails.
|
|
513
|
+
RedisConnectionError: If the connection fails.
|
|
514
|
+
RedisOperationError: If the operation fails.
|
|
515
|
+
|
|
516
|
+
"""
|
|
517
|
+
if not isinstance(key, str):
|
|
518
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
519
|
+
if value is None:
|
|
520
|
+
logger.debug("Value is none", extra={"key": key})
|
|
521
|
+
try:
|
|
522
|
+
processed_value = self._prepare_for_serialization(value)
|
|
523
|
+
if isinstance(processed_value, (dict, list)):
|
|
524
|
+
try:
|
|
525
|
+
final_value = json.dumps(processed_value, cls=CustomEncoder)
|
|
526
|
+
|
|
527
|
+
except (TypeError, ValueError) as exc:
|
|
528
|
+
logger.exception(
|
|
529
|
+
"Serialization error before setting Redis key.",
|
|
530
|
+
extra={"key": key},
|
|
531
|
+
)
|
|
532
|
+
raise RedisSerializationError(
|
|
533
|
+
"Failed to serialize value for Redis"
|
|
534
|
+
) from exc
|
|
535
|
+
|
|
536
|
+
else:
|
|
537
|
+
final_value = processed_value
|
|
538
|
+
return bool(await self._client.set(key, final_value))
|
|
539
|
+
|
|
540
|
+
except aioredis.ConnectionError as exc:
|
|
541
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
542
|
+
raise RedisConnectionError(
|
|
543
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
544
|
+
) from exc
|
|
545
|
+
|
|
546
|
+
except aioredis.RedisError as exc:
|
|
547
|
+
logger.exception("Error setting Redis key.", extra={"key": key})
|
|
548
|
+
raise RedisOperationError(f"Failed to set key '{key}'") from exc
|
|
549
|
+
|
|
550
|
+
async def get(self, key: str, model: type[T]) -> T | None:
|
|
551
|
+
"""Get a value from Redis and validate it against a Pydantic model.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
key: The key to fetch.
|
|
555
|
+
model: The Pydantic model to validate against.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
T | None: The validated model or None if key does not exist.
|
|
559
|
+
|
|
560
|
+
Raises:
|
|
561
|
+
ValueError: If the key is not a string.
|
|
562
|
+
RedisDeserializationError: If decoding or validation fails.
|
|
563
|
+
RedisConnectionError: If the connection fails.
|
|
564
|
+
RedisOperationError: If the operation fails.
|
|
565
|
+
|
|
566
|
+
"""
|
|
567
|
+
if not isinstance(key, str):
|
|
568
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
569
|
+
|
|
570
|
+
try:
|
|
571
|
+
value = await self._client.get(key)
|
|
572
|
+
if value is None:
|
|
573
|
+
logger.debug("Value is none", extra={"key": key})
|
|
574
|
+
return None
|
|
575
|
+
try:
|
|
576
|
+
return model.model_validate(json.loads(str(value)))
|
|
577
|
+
|
|
578
|
+
except json.JSONDecodeError as exc:
|
|
579
|
+
logger.exception("JSON Decode error.", extra={"key": key})
|
|
580
|
+
raise RedisDeserializationError(
|
|
581
|
+
f"Failed to decode JSON for key '{key}'"
|
|
582
|
+
) from exc
|
|
583
|
+
|
|
584
|
+
except ValidationError as exc:
|
|
585
|
+
logger.exception(
|
|
586
|
+
"Validation error.", extra={"key": key, "model": model}
|
|
587
|
+
)
|
|
588
|
+
raise RedisDeserializationError(
|
|
589
|
+
f"Validation failed for key '{key}' and model '{model.__name__}'"
|
|
590
|
+
) from exc
|
|
591
|
+
|
|
592
|
+
except aioredis.ConnectionError as exc:
|
|
593
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
594
|
+
raise RedisConnectionError(
|
|
595
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
596
|
+
) from exc
|
|
597
|
+
|
|
598
|
+
except aioredis.RedisError as exc:
|
|
599
|
+
logger.exception("Error getting Redis key.", extra={"key": key})
|
|
600
|
+
raise RedisOperationError(f"Failed to get key '{key}'") from exc
|
|
601
|
+
|
|
602
|
+
async def get_raw(self, key: str) -> Any | None:
|
|
603
|
+
"""Get a raw value from Redis without deserialization.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
key: The key to fetch.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
Any | None: The raw value or None if key does not exist.
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
ValueError: If the key is not a string.
|
|
613
|
+
RedisConnectionError: If the connection fails.
|
|
614
|
+
RedisOperationError: If the operation fails.
|
|
615
|
+
|
|
616
|
+
"""
|
|
617
|
+
if not isinstance(key, str):
|
|
618
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
value = await self._client.get(key)
|
|
622
|
+
if value is None:
|
|
623
|
+
logger.debug("Value is none", extra={"key": key})
|
|
624
|
+
return value
|
|
625
|
+
|
|
626
|
+
except aioredis.ConnectionError as exc:
|
|
627
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
628
|
+
raise RedisConnectionError(
|
|
629
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
630
|
+
) from exc
|
|
631
|
+
|
|
632
|
+
except aioredis.RedisError as exc:
|
|
633
|
+
logger.exception("Error getting Redis key.", extra={"key": key})
|
|
634
|
+
raise RedisOperationError(f"Failed to get key '{key}'") from exc
|
|
635
|
+
|
|
636
|
+
async def list_keys(self, pattern: str) -> list[str]:
|
|
637
|
+
"""List keys matching a pattern.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
pattern: The pattern to match.
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
list[str]: A list of matching keys.
|
|
644
|
+
|
|
645
|
+
Raises:
|
|
646
|
+
ValueError: If the pattern is not a string.
|
|
647
|
+
RedisConnectionError: If the connection fails.
|
|
648
|
+
RedisOperationError: If the operation fails.
|
|
649
|
+
|
|
650
|
+
"""
|
|
651
|
+
if not isinstance(pattern, str):
|
|
652
|
+
raise ValueError(f"Pattern must be a string, got {type(pattern)}")
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
keys_result = await self._client.keys(pattern)
|
|
656
|
+
return cast(list[str], keys_result)
|
|
657
|
+
|
|
658
|
+
except aioredis.ConnectionError as exc:
|
|
659
|
+
logger.exception("Redis connection error.", extra={"pattern": pattern})
|
|
660
|
+
raise RedisConnectionError(
|
|
661
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
662
|
+
) from exc
|
|
663
|
+
|
|
664
|
+
except aioredis.RedisError as exc:
|
|
665
|
+
logger.exception("Error listing Redis keys.", extra={"pattern": pattern})
|
|
666
|
+
raise RedisOperationError(
|
|
667
|
+
f"Failed to list keys for pattern '{pattern}'"
|
|
668
|
+
) from exc
|
|
669
|
+
|
|
670
|
+
async def mget(
|
|
671
|
+
self, keys: list[str], model: type[T]
|
|
672
|
+
) -> list[T | None | FailedValue]:
|
|
673
|
+
"""Get multiple values from Redis.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
keys: A list of keys to fetch.
|
|
677
|
+
model: The Pydantic model to validate against.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
list[T | None | FailedValue]: A list of validated models, None for
|
|
681
|
+
missing keys, or FailedValue for items that failed validation.
|
|
682
|
+
|
|
683
|
+
Raises:
|
|
684
|
+
ValueError: If the keys are not a list.
|
|
685
|
+
RedisConnectionError: If the connection fails.
|
|
686
|
+
RedisOperationError: If the operation fails.
|
|
687
|
+
|
|
688
|
+
"""
|
|
689
|
+
if not isinstance(keys, list):
|
|
690
|
+
raise ValueError(f"Keys must be a list, got {type(keys)}")
|
|
691
|
+
|
|
692
|
+
if not keys:
|
|
693
|
+
return []
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
values = cast(list[Any], await self._client.mget(keys))
|
|
697
|
+
results: list[T | None | FailedValue] = []
|
|
698
|
+
|
|
699
|
+
for value, key in zip(values, keys, strict=False):
|
|
700
|
+
if value is None:
|
|
701
|
+
results.append(None)
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
validated_data = model.model_validate(json.loads(str(value)))
|
|
706
|
+
results.append(validated_data)
|
|
707
|
+
|
|
708
|
+
except json.JSONDecodeError as exc:
|
|
709
|
+
logger.exception("JSON Decode error.", extra={"key": key})
|
|
710
|
+
results.append(FailedValue(key=key, error=exc))
|
|
711
|
+
continue
|
|
712
|
+
|
|
713
|
+
except ValidationError as exc:
|
|
714
|
+
logger.exception(
|
|
715
|
+
"Validation error.",
|
|
716
|
+
extra={"key": key, "model": model.__name__},
|
|
717
|
+
)
|
|
718
|
+
results.append(FailedValue(key=key, error=exc))
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
return results
|
|
722
|
+
|
|
723
|
+
except aioredis.ConnectionError as exc:
|
|
724
|
+
logger.exception("Redis connection error.", extra={"keys": keys})
|
|
725
|
+
raise RedisConnectionError(
|
|
726
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
727
|
+
) from exc
|
|
728
|
+
|
|
729
|
+
except aioredis.RedisError as exc:
|
|
730
|
+
logger.exception("Error getting multiple Redis keys.")
|
|
731
|
+
raise RedisOperationError("Failed to mget keys") from exc
|
|
732
|
+
|
|
733
|
+
async def delete(self, key: str) -> bool:
|
|
734
|
+
"""Delete a key from Redis.
|
|
735
|
+
|
|
736
|
+
Args:
|
|
737
|
+
key: The key to delete.
|
|
738
|
+
|
|
739
|
+
Returns:
|
|
740
|
+
bool: True if the key was deleted, False otherwise.
|
|
741
|
+
|
|
742
|
+
Raises:
|
|
743
|
+
ValueError: If the key is not a string.
|
|
744
|
+
RedisConnectionError: If the connection fails.
|
|
745
|
+
RedisOperationError: If the operation fails.
|
|
746
|
+
|
|
747
|
+
"""
|
|
748
|
+
if not isinstance(key, str):
|
|
749
|
+
raise ValueError(f"Key must be a string, got {type(key)}")
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
result = await self._client.delete(key)
|
|
753
|
+
logger.info("Key deleted successfully", extra={"key": key})
|
|
754
|
+
return bool(result)
|
|
755
|
+
|
|
756
|
+
except aioredis.ConnectionError as exc:
|
|
757
|
+
logger.exception("Redis connection error.", extra={"key": key})
|
|
758
|
+
raise RedisConnectionError(
|
|
759
|
+
f"Error connecting to Redis host {self.config.redis_host}"
|
|
760
|
+
) from exc
|
|
761
|
+
|
|
762
|
+
except aioredis.RedisError as exc:
|
|
763
|
+
logger.exception("Error deleting Redis key.", extra={"key": key})
|
|
764
|
+
raise RedisOperationError(f"Failed to delete key '{key}'") from exc
|