simple-dep-cache 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.github/workflows/test.yml +6 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/CHANGELOG.md +21 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/PKG-INFO +31 -1
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/README.md +30 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/decorators.py +81 -23
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/events.py +42 -16
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/manager.py +37 -22
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/types.py +5 -1
- simple_dep_cache-0.3.0/tests/test_async_callbacks.py +111 -0
- simple_dep_cache-0.3.0/tests/test_silent_backend_errors.py +207 -0
- simple_dep_cache-0.2.0/.claude/hehe +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.github/workflows/publish-pypi.yaml +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.github/workflows/static-check.yml +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.gitignore +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.pre-commit-config.yaml +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.python-version +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/.ruff.toml +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/docker-compose.yml +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/pyproject.toml +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/__init__.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/backends.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/config.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/context.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/factories.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/fakes.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/redis_backends.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/src/simple_dep_cache/utils.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/__init__.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_cache_manager.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_config.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_context.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_decorators.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_end_to_end_redis_simple.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_fakes.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_redis_backends.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/tests/test_types.py +0 -0
- {simple_dep_cache-0.2.0 → simple_dep_cache-0.3.0}/uv.lock +0 -0
|
@@ -10,6 +10,11 @@ jobs:
|
|
|
10
10
|
test:
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
12
|
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
17
|
+
|
|
13
18
|
steps:
|
|
14
19
|
- uses: actions/checkout@v5
|
|
15
20
|
|
|
@@ -17,6 +22,7 @@ jobs:
|
|
|
17
22
|
uses: astral-sh/setup-uv@v6
|
|
18
23
|
with:
|
|
19
24
|
version: "latest"
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
20
26
|
|
|
21
27
|
- name: Install dependencies
|
|
22
28
|
run: uv sync --all-extras
|
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## v0.3.0 - 2026-06-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Async event callbacks**: event callbacks registered via `on_event` /
|
|
10
|
+
`on_all_events` may now be coroutine functions. The async cache methods
|
|
11
|
+
(`aset`, `aget`, `adelete`, `aclear`, `ainvalidate_dependency`) dispatch to
|
|
12
|
+
both sync and async callbacks, awaiting the async ones.
|
|
13
|
+
- New `EventEmitter.aemit()` awaits async callbacks; `emit()` runs only sync
|
|
14
|
+
callbacks (it never leaves a coroutine un-awaited).
|
|
15
|
+
- New `EventEmitter.has_async_callbacks(event_type)`.
|
|
16
|
+
- Callback type hints widened to `CacheCallback = Callable[[CacheEvent], Any]`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- A **synchronous** cache operation (`set`/`get`/`delete`/`clear`/
|
|
21
|
+
`invalidate_dependency`) now raises `RuntimeError` if an async callback is
|
|
22
|
+
registered for the emitted event, instead of silently dropping it. Use the
|
|
23
|
+
async (`a`-prefixed) methods to dispatch async callbacks. Sync callbacks are
|
|
24
|
+
unaffected.
|
|
25
|
+
|
|
5
26
|
## v0.1.3 - 2025-09-19
|
|
6
27
|
|
|
7
28
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple-dep-cache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Redis-based caching library with intelligent dependency tracking for Python applications
|
|
5
5
|
Project-URL: Homepage, https://github.com/tintty1/simple-dep-cache
|
|
6
6
|
Project-URL: Repository, https://github.com/tintty1/simple-dep-cache
|
|
@@ -543,6 +543,36 @@ async def async_function(x):
|
|
|
543
543
|
|
|
544
544
|
Callback exceptions are caught and logged.
|
|
545
545
|
|
|
546
|
+
#### Event callbacks (sync & async)
|
|
547
|
+
|
|
548
|
+
Beyond the decorator `callback`, you can subscribe to manager-level events
|
|
549
|
+
(`SET`, `HIT`, `MISS`, `DELETE`, `INVALIDATE`, `CLEAR`). Callbacks may be sync
|
|
550
|
+
**or** async:
|
|
551
|
+
|
|
552
|
+
```python
|
|
553
|
+
from simple_dep_cache import CacheEventType, get_or_create_cache_manager
|
|
554
|
+
|
|
555
|
+
cache = get_or_create_cache_manager()
|
|
556
|
+
|
|
557
|
+
def on_invalidate(event): # sync callback
|
|
558
|
+
log.info("invalidated %s (%s entries)", event.key, event.count)
|
|
559
|
+
|
|
560
|
+
async def on_invalidate_async(event): # async callback
|
|
561
|
+
await bus.publish("cache.invalidate", {"key": event.key})
|
|
562
|
+
|
|
563
|
+
cache.on_event(CacheEventType.INVALIDATE, on_invalidate)
|
|
564
|
+
cache.on_event(CacheEventType.INVALIDATE, on_invalidate_async)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
- Async callbacks are awaited by the **async** cache methods (`aset`, `aget`,
|
|
568
|
+
`adelete`, `aclear`, `ainvalidate_dependency`) via `EventEmitter.aemit()`.
|
|
569
|
+
- The **sync** methods only dispatch sync callbacks. If an async callback is
|
|
570
|
+
registered for an event a sync method emits, that method raises
|
|
571
|
+
`RuntimeError` (a sync context can't await it) — use the async method, or a
|
|
572
|
+
sync callback.
|
|
573
|
+
- A single sync callback also runs from the async methods, so it's the simplest
|
|
574
|
+
choice when you don't need to `await` inside the callback.
|
|
575
|
+
|
|
546
576
|
**Decorators:**
|
|
547
577
|
|
|
548
578
|
- `@cache_with_deps(name, ttl, dependencies, cache_exception_types, callback)` - Works for both sync and async functions
|
|
@@ -521,6 +521,36 @@ async def async_function(x):
|
|
|
521
521
|
|
|
522
522
|
Callback exceptions are caught and logged.
|
|
523
523
|
|
|
524
|
+
#### Event callbacks (sync & async)
|
|
525
|
+
|
|
526
|
+
Beyond the decorator `callback`, you can subscribe to manager-level events
|
|
527
|
+
(`SET`, `HIT`, `MISS`, `DELETE`, `INVALIDATE`, `CLEAR`). Callbacks may be sync
|
|
528
|
+
**or** async:
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
from simple_dep_cache import CacheEventType, get_or_create_cache_manager
|
|
532
|
+
|
|
533
|
+
cache = get_or_create_cache_manager()
|
|
534
|
+
|
|
535
|
+
def on_invalidate(event): # sync callback
|
|
536
|
+
log.info("invalidated %s (%s entries)", event.key, event.count)
|
|
537
|
+
|
|
538
|
+
async def on_invalidate_async(event): # async callback
|
|
539
|
+
await bus.publish("cache.invalidate", {"key": event.key})
|
|
540
|
+
|
|
541
|
+
cache.on_event(CacheEventType.INVALIDATE, on_invalidate)
|
|
542
|
+
cache.on_event(CacheEventType.INVALIDATE, on_invalidate_async)
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- Async callbacks are awaited by the **async** cache methods (`aset`, `aget`,
|
|
546
|
+
`adelete`, `aclear`, `ainvalidate_dependency`) via `EventEmitter.aemit()`.
|
|
547
|
+
- The **sync** methods only dispatch sync callbacks. If an async callback is
|
|
548
|
+
registered for an event a sync method emits, that method raises
|
|
549
|
+
`RuntimeError` (a sync context can't await it) — use the async method, or a
|
|
550
|
+
sync callback.
|
|
551
|
+
- A single sync callback also runs from the async methods, so it's the simplest
|
|
552
|
+
choice when you don't need to `await` inside the callback.
|
|
553
|
+
|
|
524
554
|
**Decorators:**
|
|
525
555
|
|
|
526
556
|
- `@cache_with_deps(name, ttl, dependencies, cache_exception_types, callback)` - Works for both sync and async functions
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import asyncio
|
|
2
1
|
import hashlib
|
|
2
|
+
import inspect
|
|
3
3
|
import logging
|
|
4
4
|
from collections.abc import Callable
|
|
5
5
|
from functools import wraps
|
|
@@ -108,7 +108,7 @@ def _validate_callback_compatibility(
|
|
|
108
108
|
if callback is None:
|
|
109
109
|
return None
|
|
110
110
|
|
|
111
|
-
callback_is_async =
|
|
111
|
+
callback_is_async = inspect.iscoroutinefunction(callback)
|
|
112
112
|
|
|
113
113
|
if is_async_function and not callback_is_async:
|
|
114
114
|
# Sync callback with async function is fine
|
|
@@ -149,6 +149,32 @@ def _handle_callback_error(error: Exception, cache_manager: CacheManager, contex
|
|
|
149
149
|
pass
|
|
150
150
|
|
|
151
151
|
|
|
152
|
+
def _handle_backend_error(operation: str, func_name: str, error: Exception, silent: bool) -> None:
|
|
153
|
+
"""Handle backend errors by logging or re-raising based on silent flag."""
|
|
154
|
+
if not silent:
|
|
155
|
+
raise
|
|
156
|
+
logger = logging.getLogger(__name__)
|
|
157
|
+
logger.warning("Backend error during %s for %s: %s", operation, func_name, error, exc_info=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _safe_backend_op(op: Callable, silent: bool, func_name: str, operation: str) -> Any:
|
|
161
|
+
"""Execute a backend operation with optional error silencing."""
|
|
162
|
+
try:
|
|
163
|
+
return op()
|
|
164
|
+
except Exception as e:
|
|
165
|
+
_handle_backend_error(operation, func_name, e, silent)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _safe_backend_op_async(op: Callable, silent: bool, func_name: str, operation: str) -> Any:
|
|
170
|
+
"""Execute an async backend operation with optional error silencing."""
|
|
171
|
+
try:
|
|
172
|
+
return await op()
|
|
173
|
+
except Exception as e:
|
|
174
|
+
_handle_backend_error(operation, func_name, e, silent)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
152
178
|
def _cache_result_or_exception_sync(
|
|
153
179
|
cache_manager: CacheManager,
|
|
154
180
|
cache_key: str,
|
|
@@ -211,6 +237,7 @@ def cache_with_deps(
|
|
|
211
237
|
dependencies: set | None = None,
|
|
212
238
|
cache_exception_types: list[type[Exception]] | None = None,
|
|
213
239
|
callback: Callable | None = None,
|
|
240
|
+
silent_backend_errors: bool = False,
|
|
214
241
|
) -> Callable:
|
|
215
242
|
"""
|
|
216
243
|
Decorator for caching function results with dependency tracking.
|
|
@@ -226,10 +253,13 @@ def cache_with_deps(
|
|
|
226
253
|
callback: Callback function invoked on cache hit or miss.
|
|
227
254
|
Called with keyword arguments: func, cache_manager, args, kwargs, is_hit, cached_result
|
|
228
255
|
is_hit=True for cache hits, False for cache misses (optional)
|
|
256
|
+
silent_backend_errors: If True, silently log backend errors (e.g., Redis connection errors)
|
|
257
|
+
instead of raising them. The decorated function will execute normally when backend
|
|
258
|
+
errors occur. Defaults to False (optional)
|
|
229
259
|
"""
|
|
230
260
|
|
|
231
261
|
def decorator(func: Callable) -> Callable:
|
|
232
|
-
if
|
|
262
|
+
if inspect.iscoroutinefunction(func):
|
|
233
263
|
|
|
234
264
|
@wraps(func)
|
|
235
265
|
async def async_wrapper(*args, **kwargs):
|
|
@@ -244,14 +274,21 @@ def cache_with_deps(
|
|
|
244
274
|
|
|
245
275
|
cache_key = _generate_cache_key(func, args, kwargs)
|
|
246
276
|
|
|
247
|
-
|
|
277
|
+
# Try to get from cache with optional error silencing
|
|
278
|
+
cached_result = await _safe_backend_op_async(
|
|
279
|
+
lambda: active_cache_manager.aget(cache_key),
|
|
280
|
+
silent_backend_errors,
|
|
281
|
+
func.__qualname__,
|
|
282
|
+
"cache get",
|
|
283
|
+
)
|
|
284
|
+
|
|
248
285
|
if cached_result is not None:
|
|
249
286
|
cache_hit_result = _handle_cache_hit(cached_result)
|
|
250
287
|
if cache_hit_result is not None:
|
|
251
288
|
# Invoke callback for cache hit
|
|
252
289
|
if valid_callback:
|
|
253
290
|
try:
|
|
254
|
-
if
|
|
291
|
+
if inspect.iscoroutinefunction(valid_callback):
|
|
255
292
|
await valid_callback(
|
|
256
293
|
func=func,
|
|
257
294
|
cache_manager=active_cache_manager,
|
|
@@ -284,20 +321,27 @@ def cache_with_deps(
|
|
|
284
321
|
finally:
|
|
285
322
|
current_deps = get_current_dependencies()
|
|
286
323
|
effective_ttl = get_cache_ttl()
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
324
|
+
|
|
325
|
+
# Try to set cache with optional error silencing
|
|
326
|
+
await _safe_backend_op_async(
|
|
327
|
+
lambda: _cache_result_or_exception_async(
|
|
328
|
+
active_cache_manager,
|
|
329
|
+
cache_key,
|
|
330
|
+
result,
|
|
331
|
+
exception,
|
|
332
|
+
current_deps,
|
|
333
|
+
effective_ttl,
|
|
334
|
+
cache_exception_types,
|
|
335
|
+
),
|
|
336
|
+
silent_backend_errors,
|
|
337
|
+
func.__qualname__,
|
|
338
|
+
"cache set",
|
|
295
339
|
)
|
|
296
340
|
|
|
297
341
|
# Invoke callback for cache miss
|
|
298
342
|
if valid_callback:
|
|
299
343
|
try:
|
|
300
|
-
if
|
|
344
|
+
if inspect.iscoroutinefunction(valid_callback):
|
|
301
345
|
await valid_callback(
|
|
302
346
|
func=func,
|
|
303
347
|
cache_manager=active_cache_manager,
|
|
@@ -338,7 +382,14 @@ def cache_with_deps(
|
|
|
338
382
|
|
|
339
383
|
cache_key = _generate_cache_key(func, args, kwargs)
|
|
340
384
|
|
|
341
|
-
|
|
385
|
+
# Try to get from cache with optional error silencing
|
|
386
|
+
cached_result = _safe_backend_op(
|
|
387
|
+
lambda: active_cache_manager.get(cache_key),
|
|
388
|
+
silent_backend_errors,
|
|
389
|
+
func.__qualname__,
|
|
390
|
+
"cache get",
|
|
391
|
+
)
|
|
392
|
+
|
|
342
393
|
if cached_result is not None:
|
|
343
394
|
cache_hit_result = _handle_cache_hit(cached_result)
|
|
344
395
|
if cache_hit_result is not None:
|
|
@@ -368,14 +419,21 @@ def cache_with_deps(
|
|
|
368
419
|
finally:
|
|
369
420
|
current_deps = get_current_dependencies()
|
|
370
421
|
effective_ttl = get_cache_ttl()
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
422
|
+
|
|
423
|
+
# Try to set cache with optional error silencing
|
|
424
|
+
_safe_backend_op(
|
|
425
|
+
lambda: _cache_result_or_exception_sync(
|
|
426
|
+
active_cache_manager,
|
|
427
|
+
cache_key,
|
|
428
|
+
result,
|
|
429
|
+
exception,
|
|
430
|
+
current_deps,
|
|
431
|
+
effective_ttl,
|
|
432
|
+
cache_exception_types,
|
|
433
|
+
),
|
|
434
|
+
silent_backend_errors,
|
|
435
|
+
func.__qualname__,
|
|
436
|
+
"cache set",
|
|
379
437
|
)
|
|
380
438
|
|
|
381
439
|
# Invoke callback for cache miss
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import time
|
|
3
|
-
from collections.abc import Callable
|
|
3
|
+
from collections.abc import Callable, Iterator
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from enum import Enum
|
|
6
|
+
from inspect import iscoroutinefunction
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from .config import ConfigBase
|
|
@@ -38,6 +39,9 @@ class CacheEvent:
|
|
|
38
39
|
self.timestamp = time.time()
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
CacheCallback = Callable[[CacheEvent], Any]
|
|
43
|
+
|
|
44
|
+
|
|
41
45
|
class EventEmitter:
|
|
42
46
|
"""Simple event emitter for cache events."""
|
|
43
47
|
|
|
@@ -48,45 +52,67 @@ class EventEmitter:
|
|
|
48
52
|
}
|
|
49
53
|
self._global_callbacks: list[Callable] = []
|
|
50
54
|
|
|
51
|
-
def on(self, event_type: CacheEventType, callback:
|
|
55
|
+
def on(self, event_type: CacheEventType, callback: CacheCallback) -> None:
|
|
52
56
|
"""Register a callback for a specific event type."""
|
|
53
57
|
self._callbacks[event_type].append(callback)
|
|
54
58
|
|
|
55
|
-
def on_all(self, callback:
|
|
59
|
+
def on_all(self, callback: CacheCallback) -> None:
|
|
56
60
|
"""Register a callback for all event types."""
|
|
57
61
|
self._global_callbacks.append(callback)
|
|
58
62
|
|
|
59
|
-
def off(self, event_type: CacheEventType, callback:
|
|
63
|
+
def off(self, event_type: CacheEventType, callback: CacheCallback) -> bool:
|
|
60
64
|
"""Unregister a callback for a specific event type."""
|
|
61
65
|
if callback in self._callbacks[event_type]:
|
|
62
66
|
self._callbacks[event_type].remove(callback)
|
|
63
67
|
return True
|
|
64
68
|
return False
|
|
65
69
|
|
|
66
|
-
def off_all(self, callback:
|
|
70
|
+
def off_all(self, callback: CacheCallback) -> bool:
|
|
67
71
|
"""Unregister a callback from all events."""
|
|
68
72
|
if callback in self._global_callbacks:
|
|
69
73
|
self._global_callbacks.remove(callback)
|
|
70
74
|
return True
|
|
71
75
|
return False
|
|
72
76
|
|
|
77
|
+
def _iter_callbacks(self, event_type: CacheEventType) -> Iterator[Callable]:
|
|
78
|
+
"""Yield specific-then-global callbacks for an event type."""
|
|
79
|
+
yield from self._callbacks[event_type]
|
|
80
|
+
yield from self._global_callbacks
|
|
81
|
+
|
|
82
|
+
def has_async_callbacks(self, event_type: CacheEventType) -> bool:
|
|
83
|
+
"""True if any registered callback for this event type is a coroutine function."""
|
|
84
|
+
return any(iscoroutinefunction(cb) for cb in self._iter_callbacks(event_type))
|
|
85
|
+
|
|
86
|
+
def _handle_callback_error(self, error: Exception) -> None:
|
|
87
|
+
if not self.config.callback_error_silent:
|
|
88
|
+
logger.exception("Error in cache event callback: %s", error)
|
|
89
|
+
|
|
73
90
|
def emit(self, event: CacheEvent) -> None:
|
|
74
|
-
"""
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
"""Dispatch the event to all *sync* callbacks.
|
|
92
|
+
|
|
93
|
+
Async callbacks are skipped here; they are dispatched by :meth:`aemit`
|
|
94
|
+
from the manager's async methods.
|
|
95
|
+
"""
|
|
96
|
+
for callback in self._iter_callbacks(event.event_type):
|
|
97
|
+
if iscoroutinefunction(callback):
|
|
98
|
+
continue
|
|
77
99
|
try:
|
|
78
100
|
callback(event)
|
|
79
101
|
except Exception as e:
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
self._handle_callback_error(e)
|
|
103
|
+
|
|
104
|
+
async def aemit(self, event: CacheEvent) -> None:
|
|
105
|
+
"""Dispatch the event to all *async* callbacks, awaiting each.
|
|
82
106
|
|
|
83
|
-
|
|
84
|
-
|
|
107
|
+
Sync callbacks are skipped here; they are dispatched by :meth:`emit`.
|
|
108
|
+
"""
|
|
109
|
+
for callback in self._iter_callbacks(event.event_type):
|
|
110
|
+
if not iscoroutinefunction(callback):
|
|
111
|
+
continue
|
|
85
112
|
try:
|
|
86
|
-
callback(event)
|
|
113
|
+
await callback(event)
|
|
87
114
|
except Exception as e:
|
|
88
|
-
|
|
89
|
-
logger.exception("Error in cache event callback: %s", e)
|
|
115
|
+
self._handle_callback_error(e)
|
|
90
116
|
|
|
91
117
|
def clear_all(self) -> None:
|
|
92
118
|
"""Clear all callbacks."""
|
|
@@ -148,7 +174,7 @@ class StatsCollector:
|
|
|
148
174
|
self.start_time = time.time()
|
|
149
175
|
|
|
150
176
|
|
|
151
|
-
def create_logger_callback(name: str = "cache") ->
|
|
177
|
+
def create_logger_callback(name: str = "cache") -> CacheCallback:
|
|
152
178
|
"""Create a callback that logs cache events."""
|
|
153
179
|
|
|
154
180
|
def logger_callback(event: CacheEvent) -> None:
|
|
@@ -2,12 +2,11 @@ import builtins
|
|
|
2
2
|
import threading
|
|
3
3
|
import time
|
|
4
4
|
import warnings
|
|
5
|
-
from collections.abc import Callable
|
|
6
5
|
from typing import Optional
|
|
7
6
|
|
|
8
7
|
from .backends import AsyncCacheBackend, CacheBackend
|
|
9
8
|
from .config import ConfigBase, RedisConfig
|
|
10
|
-
from .events import CacheEvent, CacheEventType, EventEmitter
|
|
9
|
+
from .events import CacheCallback, CacheEvent, CacheEventType, EventEmitter
|
|
11
10
|
from .types import CacheValue
|
|
12
11
|
|
|
13
12
|
_manager_lock = threading.Lock()
|
|
@@ -115,6 +114,26 @@ class CacheManager:
|
|
|
115
114
|
else:
|
|
116
115
|
raise RuntimeError("No backend available.")
|
|
117
116
|
|
|
117
|
+
def _emit(self, event: CacheEvent) -> None:
|
|
118
|
+
"""Dispatch an event from a synchronous operation.
|
|
119
|
+
|
|
120
|
+
Sync operations can't await, so an async callback registered for this
|
|
121
|
+
event has no way to run -- that's a misconfiguration, so we raise rather
|
|
122
|
+
than silently drop it. Use the async (a*) methods for async callbacks.
|
|
123
|
+
"""
|
|
124
|
+
if self.events.has_async_callbacks(event.event_type):
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"An async callback is registered for {event.event_type}, but it was "
|
|
127
|
+
f"triggered by a synchronous cache operation and cannot be awaited. "
|
|
128
|
+
f"Use the async ('a'-prefixed) methods, or register a sync callback."
|
|
129
|
+
)
|
|
130
|
+
self.events.emit(event)
|
|
131
|
+
|
|
132
|
+
async def _aemit(self, event: CacheEvent) -> None:
|
|
133
|
+
"""Dispatch an event from an asynchronous operation (sync + async callbacks)."""
|
|
134
|
+
self.events.emit(event)
|
|
135
|
+
await self.events.aemit(event)
|
|
136
|
+
|
|
118
137
|
def set(
|
|
119
138
|
self,
|
|
120
139
|
key: str,
|
|
@@ -128,7 +147,7 @@ class CacheManager:
|
|
|
128
147
|
|
|
129
148
|
self.backend.set(key, value, ttl, dependencies)
|
|
130
149
|
|
|
131
|
-
self.
|
|
150
|
+
self._emit(
|
|
132
151
|
CacheEvent(
|
|
133
152
|
event_type=CacheEventType.SET,
|
|
134
153
|
key=key,
|
|
@@ -160,7 +179,7 @@ class CacheManager:
|
|
|
160
179
|
else:
|
|
161
180
|
raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
|
|
162
181
|
|
|
163
|
-
self.
|
|
182
|
+
await self._aemit(
|
|
164
183
|
CacheEvent(
|
|
165
184
|
event_type=CacheEventType.SET,
|
|
166
185
|
key=key,
|
|
@@ -179,12 +198,10 @@ class CacheManager:
|
|
|
179
198
|
value = self.backend.get(key)
|
|
180
199
|
|
|
181
200
|
if value is None:
|
|
182
|
-
self.
|
|
183
|
-
CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time())
|
|
184
|
-
)
|
|
201
|
+
self._emit(CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time()))
|
|
185
202
|
return None
|
|
186
203
|
|
|
187
|
-
self.
|
|
204
|
+
self._emit(
|
|
188
205
|
CacheEvent(
|
|
189
206
|
event_type=CacheEventType.HIT,
|
|
190
207
|
key=key,
|
|
@@ -210,12 +227,12 @@ class CacheManager:
|
|
|
210
227
|
raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
|
|
211
228
|
|
|
212
229
|
if value is None:
|
|
213
|
-
self.
|
|
230
|
+
await self._aemit(
|
|
214
231
|
CacheEvent(event_type=CacheEventType.MISS, key=key, timestamp=time.time())
|
|
215
232
|
)
|
|
216
233
|
return None
|
|
217
234
|
|
|
218
|
-
self.
|
|
235
|
+
await self._aemit(
|
|
219
236
|
CacheEvent(
|
|
220
237
|
event_type=CacheEventType.HIT,
|
|
221
238
|
key=key,
|
|
@@ -233,7 +250,7 @@ class CacheManager:
|
|
|
233
250
|
count = self.backend.delete(*keys)
|
|
234
251
|
|
|
235
252
|
for key in keys:
|
|
236
|
-
self.
|
|
253
|
+
self._emit(
|
|
237
254
|
CacheEvent(
|
|
238
255
|
event_type=CacheEventType.DELETE,
|
|
239
256
|
key=key,
|
|
@@ -260,7 +277,7 @@ class CacheManager:
|
|
|
260
277
|
raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
|
|
261
278
|
|
|
262
279
|
for key in keys:
|
|
263
|
-
self.
|
|
280
|
+
await self._aemit(
|
|
264
281
|
CacheEvent(
|
|
265
282
|
event_type=CacheEventType.DELETE,
|
|
266
283
|
key=key,
|
|
@@ -278,7 +295,7 @@ class CacheManager:
|
|
|
278
295
|
|
|
279
296
|
count = self.backend.clear(pattern)
|
|
280
297
|
|
|
281
|
-
self.
|
|
298
|
+
self._emit(
|
|
282
299
|
CacheEvent(
|
|
283
300
|
event_type=CacheEventType.CLEAR,
|
|
284
301
|
key=pattern,
|
|
@@ -304,7 +321,7 @@ class CacheManager:
|
|
|
304
321
|
else:
|
|
305
322
|
raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
|
|
306
323
|
|
|
307
|
-
self.
|
|
324
|
+
await self._aemit(
|
|
308
325
|
CacheEvent(
|
|
309
326
|
event_type=CacheEventType.CLEAR,
|
|
310
327
|
key=pattern,
|
|
@@ -325,7 +342,7 @@ class CacheManager:
|
|
|
325
342
|
count = self.backend.invalidate_dependency(dependency)
|
|
326
343
|
|
|
327
344
|
# Emit invalidate event
|
|
328
|
-
self.
|
|
345
|
+
self._emit(
|
|
329
346
|
CacheEvent(
|
|
330
347
|
event_type=CacheEventType.INVALIDATE,
|
|
331
348
|
key=dependency,
|
|
@@ -352,7 +369,7 @@ class CacheManager:
|
|
|
352
369
|
else:
|
|
353
370
|
raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
|
|
354
371
|
|
|
355
|
-
self.
|
|
372
|
+
await self._aemit(
|
|
356
373
|
CacheEvent(
|
|
357
374
|
event_type=CacheEventType.INVALIDATE,
|
|
358
375
|
key=dependency,
|
|
@@ -407,7 +424,7 @@ class CacheManager:
|
|
|
407
424
|
else:
|
|
408
425
|
raise RuntimeError("No backend available. Provide either 'backend' or 'async_backend'.")
|
|
409
426
|
|
|
410
|
-
def on_event(self, event_type: CacheEventType, callback:
|
|
427
|
+
def on_event(self, event_type: CacheEventType, callback: CacheCallback) -> None:
|
|
411
428
|
"""Register a callback for a specific cache event type.
|
|
412
429
|
|
|
413
430
|
Args:
|
|
@@ -416,7 +433,7 @@ class CacheManager:
|
|
|
416
433
|
"""
|
|
417
434
|
self.events.on(event_type, callback)
|
|
418
435
|
|
|
419
|
-
def on_all_events(self, callback:
|
|
436
|
+
def on_all_events(self, callback: CacheCallback) -> None:
|
|
420
437
|
"""Register a callback for all cache events.
|
|
421
438
|
|
|
422
439
|
Args:
|
|
@@ -424,9 +441,7 @@ class CacheManager:
|
|
|
424
441
|
"""
|
|
425
442
|
self.events.on_all(callback)
|
|
426
443
|
|
|
427
|
-
def remove_event_callback(
|
|
428
|
-
self, event_type: CacheEventType, callback: Callable[[CacheEvent], None]
|
|
429
|
-
) -> bool:
|
|
444
|
+
def remove_event_callback(self, event_type: CacheEventType, callback: CacheCallback) -> bool:
|
|
430
445
|
"""Remove a callback for a specific event type.
|
|
431
446
|
|
|
432
447
|
Args:
|
|
@@ -438,7 +453,7 @@ class CacheManager:
|
|
|
438
453
|
"""
|
|
439
454
|
return self.events.off(event_type, callback)
|
|
440
455
|
|
|
441
|
-
def remove_all_events_callback(self, callback:
|
|
456
|
+
def remove_all_events_callback(self, callback: CacheCallback) -> bool:
|
|
442
457
|
"""Remove a callback from all events.
|
|
443
458
|
|
|
444
459
|
Args:
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
from abc import ABC, abstractmethod
|
|
3
|
-
from typing import TYPE_CHECKING, Any, Protocol,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
4
6
|
|
|
5
7
|
from .config import ConfigBase
|
|
6
8
|
from .utils import DynamicImporter
|
|
7
9
|
|
|
8
10
|
if TYPE_CHECKING:
|
|
11
|
+
from typing import Self
|
|
12
|
+
|
|
9
13
|
import orjson
|
|
10
14
|
|
|
11
15
|
try:
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for async event-callback dispatch on the cache manager."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from simple_dep_cache.config import ConfigBase
|
|
6
|
+
from simple_dep_cache.events import CacheEvent, CacheEventType
|
|
7
|
+
from simple_dep_cache.fakes import FakeAsyncCacheBackend, FakeCacheBackend
|
|
8
|
+
from simple_dep_cache.manager import CacheManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sync_manager():
|
|
12
|
+
config = ConfigBase(prefix="t")
|
|
13
|
+
return CacheManager(config, name="t", backend=FakeCacheBackend(config))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _async_manager():
|
|
17
|
+
config = ConfigBase(prefix="t")
|
|
18
|
+
return CacheManager(config, name="t", async_backend=FakeAsyncCacheBackend(config))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestSyncDispatch:
|
|
22
|
+
def test_sync_callback_runs_on_sync_op(self):
|
|
23
|
+
seen = []
|
|
24
|
+
m = _sync_manager()
|
|
25
|
+
m.on_event(CacheEventType.INVALIDATE, lambda e: seen.append(e.key))
|
|
26
|
+
m.invalidate_dependency("dep:1")
|
|
27
|
+
assert seen == ["dep:1"]
|
|
28
|
+
|
|
29
|
+
def test_async_callback_on_sync_op_raises(self):
|
|
30
|
+
m = _sync_manager()
|
|
31
|
+
|
|
32
|
+
async def acb(event): # noqa: RUF029
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
m.on_event(CacheEventType.INVALIDATE, acb)
|
|
36
|
+
with pytest.raises(RuntimeError, match="async callback"):
|
|
37
|
+
m.invalidate_dependency("dep:1")
|
|
38
|
+
|
|
39
|
+
def test_emit_skips_async_callbacks(self):
|
|
40
|
+
# EventEmitter.emit itself must never try to call an async callback.
|
|
41
|
+
sync_seen, async_seen = [], []
|
|
42
|
+
m = _sync_manager()
|
|
43
|
+
|
|
44
|
+
async def acb(event):
|
|
45
|
+
async_seen.append(event.key)
|
|
46
|
+
|
|
47
|
+
m.on_event(CacheEventType.SET, lambda e: sync_seen.append(e.key))
|
|
48
|
+
m.on_event(CacheEventType.SET, acb)
|
|
49
|
+
# direct emit -> only sync callback runs, no coroutine leaked
|
|
50
|
+
m.events.emit(CacheEvent(event_type=CacheEventType.SET, key="k", timestamp=0.0))
|
|
51
|
+
assert sync_seen == ["k"]
|
|
52
|
+
assert async_seen == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestAsyncDispatch:
|
|
56
|
+
@pytest.mark.asyncio
|
|
57
|
+
async def test_async_callback_runs_on_async_op(self):
|
|
58
|
+
seen = []
|
|
59
|
+
m = _async_manager()
|
|
60
|
+
|
|
61
|
+
async def acb(event):
|
|
62
|
+
seen.append(event.key)
|
|
63
|
+
|
|
64
|
+
m.on_event(CacheEventType.INVALIDATE, acb)
|
|
65
|
+
await m.ainvalidate_dependency("dep:1")
|
|
66
|
+
assert seen == ["dep:1"]
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_sync_callback_also_runs_on_async_op(self):
|
|
70
|
+
seen = []
|
|
71
|
+
m = _async_manager()
|
|
72
|
+
m.on_event(CacheEventType.INVALIDATE, lambda e: seen.append(e.key))
|
|
73
|
+
await m.ainvalidate_dependency("dep:1")
|
|
74
|
+
assert seen == ["dep:1"]
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_both_sync_and_async_run_on_async_op(self):
|
|
78
|
+
sync_seen, async_seen = [], []
|
|
79
|
+
m = _async_manager()
|
|
80
|
+
|
|
81
|
+
async def acb(event):
|
|
82
|
+
async_seen.append(event.key)
|
|
83
|
+
|
|
84
|
+
m.on_event(CacheEventType.INVALIDATE, lambda e: sync_seen.append(e.key))
|
|
85
|
+
m.on_event(CacheEventType.INVALIDATE, acb)
|
|
86
|
+
await m.ainvalidate_dependency("dep:1")
|
|
87
|
+
assert sync_seen == ["dep:1"]
|
|
88
|
+
assert async_seen == ["dep:1"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestHasAsyncCallbacks:
|
|
92
|
+
def test_detection(self):
|
|
93
|
+
m = _sync_manager()
|
|
94
|
+
assert m.events.has_async_callbacks(CacheEventType.SET) is False
|
|
95
|
+
m.on_event(CacheEventType.SET, lambda e: None)
|
|
96
|
+
assert m.events.has_async_callbacks(CacheEventType.SET) is False
|
|
97
|
+
|
|
98
|
+
async def acb(event):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
m.on_event(CacheEventType.SET, acb)
|
|
102
|
+
assert m.events.has_async_callbacks(CacheEventType.SET) is True
|
|
103
|
+
|
|
104
|
+
def test_global_async_callback_detected(self):
|
|
105
|
+
m = _sync_manager()
|
|
106
|
+
|
|
107
|
+
async def acb(event):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
m.on_all_events(acb)
|
|
111
|
+
assert m.events.has_async_callbacks(CacheEventType.HIT) is True
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Tests for the silent_backend_errors parameter in cache_with_deps decorator."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import Mock
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from simple_dep_cache.decorators import cache_with_deps
|
|
8
|
+
from simple_dep_cache.fakes import FakeAsyncCacheBackend, FakeCacheBackend, FakeConfig
|
|
9
|
+
from simple_dep_cache.manager import get_or_create_cache_manager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestSilentBackendErrors:
|
|
13
|
+
"""Test cases for silent_backend_errors parameter."""
|
|
14
|
+
|
|
15
|
+
def test_silent_backend_errors_on_get(self):
|
|
16
|
+
"""Test that backend errors during get are silently logged."""
|
|
17
|
+
# Create a fake backend that will raise an error on get
|
|
18
|
+
config = FakeConfig(prefix="test_get_error")
|
|
19
|
+
backend = FakeCacheBackend(config)
|
|
20
|
+
cache_manager = get_or_create_cache_manager(
|
|
21
|
+
name="test_get_error", config=config, backend=backend
|
|
22
|
+
)
|
|
23
|
+
assert cache_manager is not None
|
|
24
|
+
|
|
25
|
+
# Mock the backend's get method to raise an error
|
|
26
|
+
backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
|
|
27
|
+
|
|
28
|
+
call_count = 0
|
|
29
|
+
|
|
30
|
+
@cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
|
|
31
|
+
def my_function(x):
|
|
32
|
+
nonlocal call_count
|
|
33
|
+
call_count += 1
|
|
34
|
+
return x * 2
|
|
35
|
+
|
|
36
|
+
# Should execute function normally despite backend error
|
|
37
|
+
result = my_function(5)
|
|
38
|
+
assert result == 10
|
|
39
|
+
assert call_count == 1
|
|
40
|
+
|
|
41
|
+
def test_silent_backend_errors_on_set(self):
|
|
42
|
+
"""Test that backend errors during set are silently logged."""
|
|
43
|
+
# Create a fake backend that will raise an error on set
|
|
44
|
+
config = FakeConfig(prefix="test_set_error")
|
|
45
|
+
backend = FakeCacheBackend(config)
|
|
46
|
+
cache_manager = get_or_create_cache_manager(
|
|
47
|
+
name="test_set_error", config=config, backend=backend
|
|
48
|
+
)
|
|
49
|
+
assert cache_manager is not None
|
|
50
|
+
|
|
51
|
+
# Mock the backend's set method to raise an error
|
|
52
|
+
backend.set = Mock(side_effect=ConnectionError("Redis connection failed"))
|
|
53
|
+
|
|
54
|
+
call_count = 0
|
|
55
|
+
|
|
56
|
+
@cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
|
|
57
|
+
def my_function(x):
|
|
58
|
+
nonlocal call_count
|
|
59
|
+
call_count += 1
|
|
60
|
+
return x * 2
|
|
61
|
+
|
|
62
|
+
# Should execute function normally despite backend error
|
|
63
|
+
result = my_function(5)
|
|
64
|
+
assert result == 10
|
|
65
|
+
assert call_count == 1
|
|
66
|
+
|
|
67
|
+
def test_silent_backend_errors_disabled_by_default(self):
|
|
68
|
+
"""Test that backend errors are raised by default (silent_backend_errors=False)."""
|
|
69
|
+
# Create a fake backend that will raise an error on get
|
|
70
|
+
config = FakeConfig(prefix="test_error_default")
|
|
71
|
+
backend = FakeCacheBackend(config)
|
|
72
|
+
cache_manager = get_or_create_cache_manager(
|
|
73
|
+
name="test_error_default", config=config, backend=backend
|
|
74
|
+
)
|
|
75
|
+
assert cache_manager is not None
|
|
76
|
+
|
|
77
|
+
# Mock the backend's get method to raise an error
|
|
78
|
+
backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
|
|
79
|
+
|
|
80
|
+
@cache_with_deps(name=cache_manager.name)
|
|
81
|
+
def my_function(x):
|
|
82
|
+
return x * 2
|
|
83
|
+
|
|
84
|
+
# Should raise the backend error
|
|
85
|
+
with pytest.raises(ConnectionError, match="Redis connection failed"):
|
|
86
|
+
my_function(5)
|
|
87
|
+
|
|
88
|
+
@pytest.mark.asyncio
|
|
89
|
+
async def test_silent_backend_errors_with_async(self):
|
|
90
|
+
"""Test that silent_backend_errors works with async functions."""
|
|
91
|
+
# Create a fake async backend that will raise an error on get
|
|
92
|
+
config = FakeConfig(prefix="test_async_error")
|
|
93
|
+
async_backend = FakeAsyncCacheBackend(config)
|
|
94
|
+
cache_manager = get_or_create_cache_manager(
|
|
95
|
+
name="test_async_error",
|
|
96
|
+
config=config,
|
|
97
|
+
async_backend=async_backend,
|
|
98
|
+
create_async_backend=True,
|
|
99
|
+
)
|
|
100
|
+
assert cache_manager is not None
|
|
101
|
+
|
|
102
|
+
# Mock the backend's get method to raise an error
|
|
103
|
+
async def mock_get(key):
|
|
104
|
+
raise ConnectionError("Redis connection failed")
|
|
105
|
+
|
|
106
|
+
async_backend.get = mock_get
|
|
107
|
+
|
|
108
|
+
call_count = 0
|
|
109
|
+
|
|
110
|
+
@cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
|
|
111
|
+
async def my_async_function(x):
|
|
112
|
+
nonlocal call_count
|
|
113
|
+
call_count += 1
|
|
114
|
+
return x * 2
|
|
115
|
+
|
|
116
|
+
# Should execute function normally despite backend error
|
|
117
|
+
result = await my_async_function(5)
|
|
118
|
+
assert result == 10
|
|
119
|
+
assert call_count == 1
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_silent_backend_errors_async_disabled_by_default(self):
|
|
123
|
+
"""Test that backend errors are raised by default in async functions."""
|
|
124
|
+
# Create a fake async backend that will raise an error on get
|
|
125
|
+
config = FakeConfig(prefix="test_async_error_default")
|
|
126
|
+
async_backend = FakeAsyncCacheBackend(config)
|
|
127
|
+
cache_manager = get_or_create_cache_manager(
|
|
128
|
+
name="test_async_error_default",
|
|
129
|
+
config=config,
|
|
130
|
+
async_backend=async_backend,
|
|
131
|
+
create_async_backend=True,
|
|
132
|
+
)
|
|
133
|
+
assert cache_manager is not None
|
|
134
|
+
|
|
135
|
+
# Mock the backend's get method to raise an error
|
|
136
|
+
async def mock_get(key):
|
|
137
|
+
raise ConnectionError("Redis connection failed")
|
|
138
|
+
|
|
139
|
+
async_backend.get = mock_get
|
|
140
|
+
|
|
141
|
+
@cache_with_deps(name=cache_manager.name)
|
|
142
|
+
async def my_async_function(x):
|
|
143
|
+
return x * 2
|
|
144
|
+
|
|
145
|
+
# Should raise the backend error
|
|
146
|
+
with pytest.raises(ConnectionError, match="Redis connection failed"):
|
|
147
|
+
await my_async_function(5)
|
|
148
|
+
|
|
149
|
+
def test_silent_backend_errors_multiple_calls(self):
|
|
150
|
+
"""Test that function executes correctly with persistent backend errors."""
|
|
151
|
+
config = FakeConfig(prefix="test_multiple_calls")
|
|
152
|
+
backend = FakeCacheBackend(config)
|
|
153
|
+
cache_manager = get_or_create_cache_manager(
|
|
154
|
+
name="test_multiple_calls", config=config, backend=backend
|
|
155
|
+
)
|
|
156
|
+
assert cache_manager is not None
|
|
157
|
+
|
|
158
|
+
# Mock the backend to always fail
|
|
159
|
+
backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
|
|
160
|
+
backend.set = Mock(side_effect=ConnectionError("Redis connection failed"))
|
|
161
|
+
|
|
162
|
+
call_count = 0
|
|
163
|
+
|
|
164
|
+
@cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
|
|
165
|
+
def my_function(x):
|
|
166
|
+
nonlocal call_count
|
|
167
|
+
call_count += 1
|
|
168
|
+
return x * 2
|
|
169
|
+
|
|
170
|
+
# Call multiple times - should work each time
|
|
171
|
+
result1 = my_function(5)
|
|
172
|
+
result2 = my_function(10)
|
|
173
|
+
result3 = my_function(5) # Same arg as first call
|
|
174
|
+
|
|
175
|
+
assert result1 == 10
|
|
176
|
+
assert result2 == 20
|
|
177
|
+
assert result3 == 10
|
|
178
|
+
assert call_count == 3 # Function called every time (no caching due to backend errors)
|
|
179
|
+
|
|
180
|
+
def test_silent_backend_errors_partial_failure(self):
|
|
181
|
+
"""Test behavior when only get fails but set works."""
|
|
182
|
+
config = FakeConfig(prefix="test_partial_failure")
|
|
183
|
+
backend = FakeCacheBackend(config)
|
|
184
|
+
cache_manager = get_or_create_cache_manager(
|
|
185
|
+
name="test_partial_failure", config=config, backend=backend
|
|
186
|
+
)
|
|
187
|
+
assert cache_manager is not None
|
|
188
|
+
|
|
189
|
+
# Mock the backend's get to fail, but set works normally
|
|
190
|
+
backend.get = Mock(side_effect=ConnectionError("Redis connection failed"))
|
|
191
|
+
call_count = 0
|
|
192
|
+
|
|
193
|
+
@cache_with_deps(name=cache_manager.name, silent_backend_errors=True)
|
|
194
|
+
def my_function(x):
|
|
195
|
+
nonlocal call_count
|
|
196
|
+
call_count += 1
|
|
197
|
+
return x * 2
|
|
198
|
+
|
|
199
|
+
# First call: get fails, function executes, set succeeds
|
|
200
|
+
result1 = my_function(5)
|
|
201
|
+
assert result1 == 10
|
|
202
|
+
assert call_count == 1
|
|
203
|
+
|
|
204
|
+
# Second call: get still fails, function executes again
|
|
205
|
+
result2 = my_function(5)
|
|
206
|
+
assert result2 == 10
|
|
207
|
+
assert call_count == 2 # Function called again due to get failure
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|