oplnk-python-utils 1.2.5__tar.gz → 1.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.
- {oplnk_python_utils-1.2.5/oplnk_python_utils.egg-info → oplnk_python_utils-1.3.0}/PKG-INFO +1 -1
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/__init__.py +1 -1
- oplnk_python_utils-1.3.0/oplnk_python_utils/cache/cache_utils.py +447 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/query_builder.py +1523 -1452
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0/oplnk_python_utils.egg-info}/PKG-INFO +1 -1
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/setup.py +1 -1
- oplnk_python_utils-1.2.5/oplnk_python_utils/cache/cache_utils.py +0 -278
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/LICENSE +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/MANIFEST.in +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/README.md +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/cache/__init__.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/__init__.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/data.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/parsing.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/core/serialization.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/query/__init__.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils/reports/__init__.py +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/SOURCES.txt +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/not-zip-safe +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/requires.txt +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/oplnk_python_utils.egg-info/top_level.txt +0 -0
- {oplnk_python_utils-1.2.5 → oplnk_python_utils-1.3.0}/setup.cfg +0 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache utilities - Generic caching functions that can be used across projects
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Callable, Any, Optional, Union
|
|
11
|
+
from fastapi.encoders import jsonable_encoder
|
|
12
|
+
from bson.objectid import ObjectId
|
|
13
|
+
from fastapi import Request
|
|
14
|
+
import functools
|
|
15
|
+
from redis.exceptions import (
|
|
16
|
+
ConnectionError as RedisConnectionError,
|
|
17
|
+
TimeoutError as RedisTimeoutError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Import core utilities
|
|
21
|
+
from ..core.data import normalize
|
|
22
|
+
from ..core.serialization import datetime_serializer
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_cache_id(params: dict) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Utility to generate a cache key hash from a dictionary.
|
|
30
|
+
Creates a consistent hash based on normalized dictionary content.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
params: Dictionary of parameters to hash
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Cache key string
|
|
37
|
+
"""
|
|
38
|
+
normalized = normalize(params)
|
|
39
|
+
encoded_data = jsonable_encoder(normalized, custom_encoder={ObjectId: str})
|
|
40
|
+
serialized = json.dumps(encoded_data, separators=(",", ":"), ensure_ascii=True)
|
|
41
|
+
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_or_set_cache(
|
|
45
|
+
redis_client,
|
|
46
|
+
cache_key: str,
|
|
47
|
+
fetch_func: Callable,
|
|
48
|
+
clear_cache: Union[str, bool] = False,
|
|
49
|
+
ttl: Optional[int] = None,
|
|
50
|
+
serializer: Optional[Callable] = None,
|
|
51
|
+
max_retries: int = 3,
|
|
52
|
+
retry_delay: float = 0.1,
|
|
53
|
+
) -> Any:
|
|
54
|
+
"""
|
|
55
|
+
Retrieve data from cache if available, otherwise fetch, cache, and return it.
|
|
56
|
+
Optionally clear cache before fetching.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
redis_client: Redis client instance
|
|
60
|
+
cache_key: Key to use for caching
|
|
61
|
+
fetch_func: Function to call if cache miss
|
|
62
|
+
clear_cache: Pattern to clear or boolean to clear specific key
|
|
63
|
+
ttl: Time to live in seconds
|
|
64
|
+
serializer: Function to serialize data before caching
|
|
65
|
+
max_retries: Maximum number of retry attempts for Redis operations
|
|
66
|
+
retry_delay: Delay between retry attempts in seconds
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Cached or fetched data
|
|
70
|
+
"""
|
|
71
|
+
# Intentar limpiar cache si se solicita
|
|
72
|
+
if clear_cache:
|
|
73
|
+
invalidate_cache_keys(
|
|
74
|
+
redis_client, clear_cache if isinstance(clear_cache, str) else cache_key
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Intentar obtener del cache
|
|
78
|
+
cached_data = _execute_redis_op(
|
|
79
|
+
lambda: redis_client.get(cache_key) if redis_client.exists(cache_key) else None,
|
|
80
|
+
max_retries=max_retries,
|
|
81
|
+
retry_delay=retry_delay,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if cached_data is not None:
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(cached_data)
|
|
87
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
88
|
+
logger.warning(f"Failed to decode cached data for key {cache_key}: {e}")
|
|
89
|
+
|
|
90
|
+
# Cache miss o error: obtener datos frescos
|
|
91
|
+
data = fetch_func()
|
|
92
|
+
if serializer:
|
|
93
|
+
data = serializer(data)
|
|
94
|
+
|
|
95
|
+
encoded_data = jsonable_encoder(data, custom_encoder={ObjectId: str})
|
|
96
|
+
|
|
97
|
+
# Intentar guardar en cache (falla silenciosamente si Redis no está disponible)
|
|
98
|
+
_execute_redis_op(
|
|
99
|
+
lambda: redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl),
|
|
100
|
+
max_retries=max_retries,
|
|
101
|
+
retry_delay=retry_delay,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return encoded_data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def invalidate_cache_keys(redis_client, pattern: str) -> int:
|
|
108
|
+
"""
|
|
109
|
+
Utility to invalidate cache keys matching a pattern.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
redis_client: Redis client instance
|
|
113
|
+
pattern: Pattern to match keys for deletion
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Number of keys deleted (0 if Redis is unavailable)
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
cursor = 0
|
|
120
|
+
batch_size = 1000
|
|
121
|
+
total_deleted = 0
|
|
122
|
+
|
|
123
|
+
while True:
|
|
124
|
+
cursor, keys = redis_client.scan(
|
|
125
|
+
cursor=cursor, match=pattern, count=batch_size
|
|
126
|
+
)
|
|
127
|
+
if keys:
|
|
128
|
+
redis_client.unlink(*keys)
|
|
129
|
+
total_deleted += len(keys)
|
|
130
|
+
if cursor == 0:
|
|
131
|
+
break
|
|
132
|
+
|
|
133
|
+
return total_deleted
|
|
134
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
135
|
+
logger.warning(f"Redis connection error during cache invalidation: {e}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def build_cache_key(
|
|
140
|
+
request: Request, api_name: str, collection: str, endpoint_type: str
|
|
141
|
+
) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Builds a cache key based on the request, api name, collection, and endpoint type.
|
|
144
|
+
|
|
145
|
+
Incorporates HTTP method, URL path, path parameters and query parameters to
|
|
146
|
+
avoid collisions between routes that differ only by path params.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
request: FastAPI request object
|
|
150
|
+
api_name: Name of the API
|
|
151
|
+
collection: Name of the collection
|
|
152
|
+
endpoint_type: Type of endpoint
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Cache key string
|
|
156
|
+
"""
|
|
157
|
+
# Ensure plain dicts for hashing
|
|
158
|
+
query_params_dict = dict(request.query_params) if request.query_params else {}
|
|
159
|
+
path_params_dict = (
|
|
160
|
+
dict(request.path_params) if getattr(request, "path_params", None) else {}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
key_material = {
|
|
164
|
+
"method": request.method,
|
|
165
|
+
"path": request.url.path,
|
|
166
|
+
"path_params": path_params_dict,
|
|
167
|
+
"query": query_params_dict,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Include request body for methods where it affects the response identity
|
|
171
|
+
if request.method in {"POST", "PUT"}:
|
|
172
|
+
content_type = (request.headers.get("content-type") or "").split(";")[0].lower()
|
|
173
|
+
body_hash: Optional[str] = None
|
|
174
|
+
if content_type == "application/json":
|
|
175
|
+
try:
|
|
176
|
+
json_body = await request.json()
|
|
177
|
+
normalized_body = normalize(json_body)
|
|
178
|
+
encoded_body = jsonable_encoder(
|
|
179
|
+
normalized_body, custom_encoder={ObjectId: str}
|
|
180
|
+
)
|
|
181
|
+
key_material["body"] = encoded_body
|
|
182
|
+
except Exception:
|
|
183
|
+
body_bytes = await request.body()
|
|
184
|
+
if body_bytes:
|
|
185
|
+
body_hash = hashlib.sha256(body_bytes).hexdigest()
|
|
186
|
+
else:
|
|
187
|
+
body_bytes = await request.body()
|
|
188
|
+
if body_bytes:
|
|
189
|
+
body_hash = hashlib.sha256(body_bytes).hexdigest()
|
|
190
|
+
|
|
191
|
+
if body_hash:
|
|
192
|
+
key_material["body_hash"] = body_hash
|
|
193
|
+
|
|
194
|
+
hashed = generate_cache_id(key_material)
|
|
195
|
+
return f"{api_name}:{collection}:{endpoint_type}:{hashed}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _is_async_client(redis_client) -> bool:
|
|
199
|
+
"""
|
|
200
|
+
Detecta si el cliente Redis es asíncrono.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
redis_client: Cliente Redis a verificar
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True si es asíncrono, False si es síncrono
|
|
207
|
+
"""
|
|
208
|
+
return (
|
|
209
|
+
hasattr(redis_client, "__aenter__") or "async" in type(redis_client).__module__
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _execute_redis_op(
|
|
214
|
+
operation: Callable, max_retries: int = 3, retry_delay: float = 0.1
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""
|
|
217
|
+
Ejecuta operación Redis síncrona con reintentos y degradación elegante.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
operation: Función que ejecuta la operación Redis
|
|
221
|
+
max_retries: Número máximo de reintentos
|
|
222
|
+
retry_delay: Tiempo de espera entre reintentos en segundos
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Resultado de la operación o None si falla después de todos los reintentos
|
|
226
|
+
"""
|
|
227
|
+
for attempt in range(max_retries):
|
|
228
|
+
try:
|
|
229
|
+
return operation()
|
|
230
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
231
|
+
if attempt == max_retries - 1:
|
|
232
|
+
logger.warning(f"Redis unavailable after {max_retries} attempts: {e}")
|
|
233
|
+
return None
|
|
234
|
+
time.sleep(retry_delay)
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def _execute_redis_op_async(
|
|
239
|
+
operation: Callable, max_retries: int = 3, retry_delay: float = 0.1
|
|
240
|
+
) -> Any:
|
|
241
|
+
"""
|
|
242
|
+
Ejecuta operación Redis asíncrona con reintentos y degradación elegante.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
operation: Función async que ejecuta la operación Redis
|
|
246
|
+
max_retries: Número máximo de reintentos
|
|
247
|
+
retry_delay: Tiempo de espera entre reintentos en segundos
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Resultado de la operación o None si falla después de todos los reintentos
|
|
251
|
+
"""
|
|
252
|
+
for attempt in range(max_retries):
|
|
253
|
+
try:
|
|
254
|
+
return await operation()
|
|
255
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
256
|
+
if attempt == max_retries - 1:
|
|
257
|
+
logger.warning(f"Redis unavailable after {max_retries} attempts: {e}")
|
|
258
|
+
return None
|
|
259
|
+
await asyncio.sleep(retry_delay)
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cache_response(
|
|
264
|
+
redis_client,
|
|
265
|
+
api_name: str,
|
|
266
|
+
collection: str,
|
|
267
|
+
endpoint_type: str,
|
|
268
|
+
tags: list[str],
|
|
269
|
+
ttl: int | None = None,
|
|
270
|
+
use_cache: bool = True,
|
|
271
|
+
max_retries: int = 3,
|
|
272
|
+
):
|
|
273
|
+
"""
|
|
274
|
+
Decorator to cache the response of an endpoint.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
redis_client: Redis client instance (sync or async)
|
|
278
|
+
api_name: Name of the API
|
|
279
|
+
collection: Name of the collection
|
|
280
|
+
endpoint_type: Type of endpoint
|
|
281
|
+
tags: List of tags to use for caching
|
|
282
|
+
ttl: Time to live in seconds
|
|
283
|
+
use_cache: Whether to use cache
|
|
284
|
+
max_retries: Maximum number of retry attempts for Redis operations
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Decorator function
|
|
288
|
+
"""
|
|
289
|
+
is_async = _is_async_client(redis_client)
|
|
290
|
+
|
|
291
|
+
def decorator(func: Callable):
|
|
292
|
+
@functools.wraps(func)
|
|
293
|
+
async def wrapper(*args, **kwargs):
|
|
294
|
+
if not use_cache:
|
|
295
|
+
return await func(*args, **kwargs)
|
|
296
|
+
|
|
297
|
+
# Detectar el Request
|
|
298
|
+
request: Optional[Request] = kwargs.get("request")
|
|
299
|
+
if request is None:
|
|
300
|
+
for arg in args:
|
|
301
|
+
if isinstance(arg, Request):
|
|
302
|
+
request = arg
|
|
303
|
+
break
|
|
304
|
+
if request is None:
|
|
305
|
+
raise ValueError("Request object not found in endpoint parameters.")
|
|
306
|
+
|
|
307
|
+
query_params = request.query_params
|
|
308
|
+
clear_cache_value = query_params.get("clear_cache")
|
|
309
|
+
if isinstance(clear_cache_value, str):
|
|
310
|
+
clear_cache_flag = clear_cache_value.lower() in {
|
|
311
|
+
"1",
|
|
312
|
+
"true",
|
|
313
|
+
"yes",
|
|
314
|
+
"y",
|
|
315
|
+
}
|
|
316
|
+
else:
|
|
317
|
+
clear_cache_flag = bool(clear_cache_value)
|
|
318
|
+
|
|
319
|
+
# Intentar limpiar cache si se solicita
|
|
320
|
+
if clear_cache_flag:
|
|
321
|
+
try:
|
|
322
|
+
invalidate_tag(redis_client, f"{collection}")
|
|
323
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
324
|
+
logger.warning(f"Redis connection error during cache clear: {e}")
|
|
325
|
+
|
|
326
|
+
# Construir clave
|
|
327
|
+
cache_key = await build_cache_key(
|
|
328
|
+
request, api_name, collection, endpoint_type
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
# Intentar obtener del cache
|
|
332
|
+
cached_data = None
|
|
333
|
+
if is_async:
|
|
334
|
+
|
|
335
|
+
async def get_cached_data():
|
|
336
|
+
exists_result = await redis_client.exists(cache_key)
|
|
337
|
+
if exists_result:
|
|
338
|
+
return await redis_client.get(cache_key)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
cached_data = await _execute_redis_op_async(
|
|
342
|
+
get_cached_data, max_retries=max_retries
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
cached_data = _execute_redis_op(
|
|
346
|
+
lambda: (
|
|
347
|
+
redis_client.get(cache_key)
|
|
348
|
+
if redis_client.exists(cache_key)
|
|
349
|
+
else None
|
|
350
|
+
),
|
|
351
|
+
max_retries=max_retries,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
if cached_data is not None:
|
|
355
|
+
try:
|
|
356
|
+
return json.loads(cached_data)
|
|
357
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
358
|
+
logger.warning(
|
|
359
|
+
f"Failed to decode cached data for key {cache_key}: {e}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Cache miss o error: ejecutar función y cachear respuesta
|
|
363
|
+
response = await func(*args, **kwargs)
|
|
364
|
+
encoded_data = jsonable_encoder(response, custom_encoder={ObjectId: str})
|
|
365
|
+
|
|
366
|
+
# Intentar guardar en cache (falla silenciosamente si Redis no está disponible)
|
|
367
|
+
if is_async:
|
|
368
|
+
|
|
369
|
+
async def set_cache_data():
|
|
370
|
+
await redis_client.set(cache_key, json.dumps(encoded_data), ex=ttl)
|
|
371
|
+
|
|
372
|
+
await _execute_redis_op_async(set_cache_data, max_retries=max_retries)
|
|
373
|
+
# Agregar tags
|
|
374
|
+
for tag in tags:
|
|
375
|
+
# Capturar el valor de tag en la closure usando parámetro por defecto
|
|
376
|
+
async def add_tag(tag_name=tag):
|
|
377
|
+
await redis_client.sadd(f"tag:{tag_name}", cache_key)
|
|
378
|
+
|
|
379
|
+
await _execute_redis_op_async(add_tag, max_retries=max_retries)
|
|
380
|
+
else:
|
|
381
|
+
_execute_redis_op(
|
|
382
|
+
lambda: redis_client.set(
|
|
383
|
+
cache_key, json.dumps(encoded_data), ex=ttl
|
|
384
|
+
),
|
|
385
|
+
max_retries=max_retries,
|
|
386
|
+
)
|
|
387
|
+
# Agregar tags
|
|
388
|
+
for tag in tags:
|
|
389
|
+
_execute_redis_op(
|
|
390
|
+
lambda t=tag: redis_client.sadd(f"tag:{t}", cache_key),
|
|
391
|
+
max_retries=max_retries,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return encoded_data
|
|
395
|
+
|
|
396
|
+
return wrapper
|
|
397
|
+
|
|
398
|
+
return decorator
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cache_factory(redis_client, api_name: str, collection: str):
|
|
402
|
+
"""
|
|
403
|
+
Factory to create cache decorators for different endpoint types.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
redis_client: Redis client instance
|
|
407
|
+
api_name: Name of the API
|
|
408
|
+
collection: Name of the collection
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Decorator function
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def wrapper(
|
|
415
|
+
endpoint_type: str,
|
|
416
|
+
tags: list[str],
|
|
417
|
+
ttl: int | None = None,
|
|
418
|
+
use_cache: bool = True,
|
|
419
|
+
):
|
|
420
|
+
return cache_response(
|
|
421
|
+
redis_client=redis_client,
|
|
422
|
+
api_name=api_name,
|
|
423
|
+
collection=collection,
|
|
424
|
+
endpoint_type=endpoint_type,
|
|
425
|
+
tags=tags,
|
|
426
|
+
ttl=ttl,
|
|
427
|
+
use_cache=use_cache,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return wrapper
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def invalidate_tag(redis_client, tag: str):
|
|
434
|
+
"""
|
|
435
|
+
Utility to invalidate cache keys associated with a tag.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
redis_client: Redis client instance
|
|
439
|
+
tag: Tag to invalidate
|
|
440
|
+
"""
|
|
441
|
+
try:
|
|
442
|
+
keys = redis_client.smembers(f"tag:{tag}")
|
|
443
|
+
for key in keys:
|
|
444
|
+
redis_client.delete(key)
|
|
445
|
+
redis_client.delete(f"tag:{tag}")
|
|
446
|
+
except (RedisConnectionError, RedisTimeoutError) as e:
|
|
447
|
+
logger.warning(f"Redis connection error during tag invalidation: {e}")
|