cachu 0.2.4__tar.gz → 0.2.5__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.
Files changed (47) hide show
  1. {cachu-0.2.4 → cachu-0.2.5}/PKG-INFO +1 -2
  2. {cachu-0.2.4 → cachu-0.2.5}/pyproject.toml +8 -9
  3. {cachu-0.2.4 → cachu-0.2.5}/setup.cfg +1 -1
  4. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/__init__.py +4 -5
  5. cachu-0.2.5/src/cachu/backends/__init__.py +112 -0
  6. cachu-0.2.5/src/cachu/backends/memory.py +204 -0
  7. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/backends/redis.py +86 -65
  8. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/backends/sqlite.py +163 -123
  9. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/config.py +6 -0
  10. cachu-0.2.5/src/cachu/decorator.py +519 -0
  11. cachu-0.2.5/src/cachu/mutex.py +247 -0
  12. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/operations.py +27 -28
  13. {cachu-0.2.4 → cachu-0.2.5}/src/cachu.egg-info/PKG-INFO +1 -2
  14. {cachu-0.2.4 → cachu-0.2.5}/src/cachu.egg-info/SOURCES.txt +5 -1
  15. {cachu-0.2.4 → cachu-0.2.5}/src/cachu.egg-info/requires.txt +0 -1
  16. {cachu-0.2.4 → cachu-0.2.5}/tests/test_async_memory.py +8 -8
  17. {cachu-0.2.4 → cachu-0.2.5}/tests/test_async_redis.py +31 -31
  18. {cachu-0.2.4 → cachu-0.2.5}/tests/test_async_sqlite.py +39 -39
  19. {cachu-0.2.4 → cachu-0.2.5}/tests/test_clearing.py +109 -12
  20. cachu-0.2.5/tests/test_dogpile.py +331 -0
  21. cachu-0.2.5/tests/test_error_handling.py +173 -0
  22. cachu-0.2.5/tests/test_helpers.py +299 -0
  23. {cachu-0.2.4 → cachu-0.2.5}/tests/test_memory_cache.py +26 -0
  24. cachu-0.2.5/tests/test_mutex.py +303 -0
  25. {cachu-0.2.4 → cachu-0.2.5}/tests/test_namespace_isolation.py +8 -3
  26. cachu-0.2.5/tests/test_ttl_isolation.py +233 -0
  27. cachu-0.2.4/src/cachu/backends/__init__.py +0 -95
  28. cachu-0.2.4/src/cachu/backends/file.py +0 -10
  29. cachu-0.2.4/src/cachu/backends/memory.py +0 -202
  30. cachu-0.2.4/src/cachu/decorator.py +0 -537
  31. cachu-0.2.4/tests/test_ttl_isolation.py +0 -246
  32. {cachu-0.2.4 → cachu-0.2.5}/README.md +0 -0
  33. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/keys.py +0 -0
  34. {cachu-0.2.4 → cachu-0.2.5}/src/cachu/types.py +0 -0
  35. {cachu-0.2.4 → cachu-0.2.5}/src/cachu.egg-info/dependency_links.txt +0 -0
  36. {cachu-0.2.4 → cachu-0.2.5}/src/cachu.egg-info/top_level.txt +0 -0
  37. {cachu-0.2.4 → cachu-0.2.5}/tests/test_config.py +0 -0
  38. {cachu-0.2.4 → cachu-0.2.5}/tests/test_defaultcache.py +0 -0
  39. {cachu-0.2.4 → cachu-0.2.5}/tests/test_delete_keys.py +0 -0
  40. {cachu-0.2.4 → cachu-0.2.5}/tests/test_disable.py +0 -0
  41. {cachu-0.2.4 → cachu-0.2.5}/tests/test_exclude_params.py +0 -0
  42. {cachu-0.2.4 → cachu-0.2.5}/tests/test_file_cache.py +0 -0
  43. {cachu-0.2.4 → cachu-0.2.5}/tests/test_integration.py +0 -0
  44. {cachu-0.2.4 → cachu-0.2.5}/tests/test_namespace.py +0 -0
  45. {cachu-0.2.4 → cachu-0.2.5}/tests/test_redis_cache.py +0 -0
  46. {cachu-0.2.4 → cachu-0.2.5}/tests/test_set_keys.py +0 -0
  47. {cachu-0.2.4 → cachu-0.2.5}/tests/test_sqlite_backend.py +0 -0
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachu
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Flexible caching library with sync and async support for memory, file (SQLite), and Redis backends
5
5
  Author: bissli
6
6
  License-Expression: 0BSD
7
7
  Project-URL: Repository, https://github.com/bissli/cachu.git
8
8
  Requires-Python: >=3.10
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: dogpile.cache
11
10
  Requires-Dist: func-timeout
12
11
  Provides-Extra: async
13
12
  Requires-Dist: aiosqlite; extra == "async"
@@ -1,26 +1,25 @@
1
1
  [project]
2
2
  name = "cachu"
3
- version = "0.2.4"
3
+ version = "0.2.5"
4
4
  description = "Flexible caching library with sync and async support for memory, file (SQLite), and Redis backends"
5
5
  readme = "README.md"
6
6
  license = "0BSD"
7
7
  authors = [{ name = "bissli" }]
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
- "dogpile.cache",
11
- "func-timeout",
10
+ "func-timeout",
12
11
  ]
13
12
 
14
13
  [project.optional-dependencies]
15
14
  async = ["aiosqlite"]
16
15
  redis = ["redis>=4.2.0"]
17
16
  test = [
18
- "pytest",
19
- "pytest-asyncio",
20
- "pytest-mock",
21
- "redis>=4.2.0",
22
- "testcontainers[redis]",
23
- "aiosqlite",
17
+ "pytest",
18
+ "pytest-asyncio",
19
+ "pytest-mock",
20
+ "redis>=4.2.0",
21
+ "testcontainers[redis]",
22
+ "aiosqlite",
24
23
  ]
25
24
 
26
25
  [project.urls]
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 0.2.4
2
+ current_version = 0.2.5
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -1,12 +1,12 @@
1
1
  """Flexible caching library with support for memory, file, and Redis backends.
2
2
  """
3
- __version__ = '0.2.4'
3
+ __version__ = '0.2.5'
4
4
 
5
- from .backends import AsyncBackend, Backend
5
+ from .backends import Backend
6
6
  from .backends.redis import get_redis_client
7
7
  from .config import configure, disable, enable, get_all_configs, get_config
8
8
  from .config import is_disabled
9
- from .decorator import async_cache, cache, clear_async_backends
9
+ from .decorator import aget_backend, cache, clear_async_backends
10
10
  from .decorator import get_async_backend, get_async_cache_info, get_backend
11
11
  from .operations import async_cache_clear, async_cache_delete, async_cache_get
12
12
  from .operations import async_cache_info, async_cache_set, cache_clear
@@ -26,10 +26,9 @@ __all__ = [
26
26
  'cache_clear',
27
27
  'cache_info',
28
28
  'get_backend',
29
+ 'aget_backend',
29
30
  'get_redis_client',
30
31
  'Backend',
31
- 'AsyncBackend',
32
- 'async_cache',
33
32
  'async_cache_get',
34
33
  'async_cache_set',
35
34
  'async_cache_delete',
@@ -0,0 +1,112 @@
1
+ """Cache backend implementations.
2
+ """
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncIterator, Iterator
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from ..mutex import AsyncCacheMutex, CacheMutex
9
+
10
+ NO_VALUE = object()
11
+
12
+
13
+ class Backend(ABC):
14
+ """Abstract base class for cache backends with both sync and async interfaces.
15
+ """
16
+
17
+ # ===== Sync interface =====
18
+
19
+ @abstractmethod
20
+ def get(self, key: str) -> Any:
21
+ """Get value by key. Returns NO_VALUE if not found.
22
+ """
23
+
24
+ @abstractmethod
25
+ def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
26
+ """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
27
+ """
28
+
29
+ @abstractmethod
30
+ def set(self, key: str, value: Any, ttl: int) -> None:
31
+ """Set value with TTL in seconds.
32
+ """
33
+
34
+ @abstractmethod
35
+ def delete(self, key: str) -> None:
36
+ """Delete value by key.
37
+ """
38
+
39
+ @abstractmethod
40
+ def clear(self, pattern: str | None = None) -> int:
41
+ """Clear entries matching pattern. Returns count of cleared entries.
42
+ """
43
+
44
+ @abstractmethod
45
+ def keys(self, pattern: str | None = None) -> Iterator[str]:
46
+ """Iterate over keys matching pattern.
47
+ """
48
+
49
+ @abstractmethod
50
+ def count(self, pattern: str | None = None) -> int:
51
+ """Count keys matching pattern.
52
+ """
53
+
54
+ @abstractmethod
55
+ def get_mutex(self, key: str) -> 'CacheMutex':
56
+ """Get a mutex for dogpile prevention on the given key.
57
+ """
58
+
59
+ # ===== Async interface =====
60
+
61
+ @abstractmethod
62
+ async def aget(self, key: str) -> Any:
63
+ """Async get value by key. Returns NO_VALUE if not found.
64
+ """
65
+
66
+ @abstractmethod
67
+ async def aget_with_metadata(self, key: str) -> tuple[Any, float | None]:
68
+ """Async get value and creation timestamp. Returns (NO_VALUE, None) if not found.
69
+ """
70
+
71
+ @abstractmethod
72
+ async def aset(self, key: str, value: Any, ttl: int) -> None:
73
+ """Async set value with TTL in seconds.
74
+ """
75
+
76
+ @abstractmethod
77
+ async def adelete(self, key: str) -> None:
78
+ """Async delete value by key.
79
+ """
80
+
81
+ @abstractmethod
82
+ async def aclear(self, pattern: str | None = None) -> int:
83
+ """Async clear entries matching pattern. Returns count of cleared entries.
84
+ """
85
+
86
+ @abstractmethod
87
+ def akeys(self, pattern: str | None = None) -> AsyncIterator[str]:
88
+ """Async iterate over keys matching pattern.
89
+ """
90
+
91
+ @abstractmethod
92
+ async def acount(self, pattern: str | None = None) -> int:
93
+ """Async count keys matching pattern.
94
+ """
95
+
96
+ @abstractmethod
97
+ def get_async_mutex(self, key: str) -> 'AsyncCacheMutex':
98
+ """Get an async mutex for dogpile prevention on the given key.
99
+ """
100
+
101
+ # ===== Lifecycle =====
102
+
103
+ def close(self) -> None:
104
+ """Close the backend and release resources.
105
+ """
106
+
107
+ async def aclose(self) -> None:
108
+ """Async close the backend and release resources.
109
+ """
110
+
111
+
112
+ __all__ = ['Backend', 'NO_VALUE']
@@ -0,0 +1,204 @@
1
+ """Memory cache backend implementation.
2
+ """
3
+ import asyncio
4
+ import fnmatch
5
+ import pickle
6
+ import threading
7
+ import time
8
+ from collections.abc import AsyncIterator, Iterator
9
+ from typing import Any
10
+
11
+ from ..mutex import AsyncioMutex, CacheMutex, ThreadingMutex
12
+ from . import NO_VALUE, Backend
13
+
14
+
15
+ class MemoryBackend(Backend):
16
+ """Thread-safe in-memory cache backend with both sync and async interfaces.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ self._cache: dict[str, tuple[bytes, float, float]] = {}
21
+ self._sync_lock = threading.RLock()
22
+ self._async_lock: asyncio.Lock | None = None
23
+
24
+ def _get_async_lock(self) -> asyncio.Lock:
25
+ """Lazy-create async lock (must be called from async context).
26
+ """
27
+ if self._async_lock is None:
28
+ self._async_lock = asyncio.Lock()
29
+ return self._async_lock
30
+
31
+ # ===== Core logic (no locking) =====
32
+
33
+ def _do_get(self, key: str) -> tuple[Any, float | None]:
34
+ """Get value and metadata without locking.
35
+ """
36
+ entry = self._cache.get(key)
37
+ if entry is None:
38
+ return NO_VALUE, None
39
+
40
+ pickled_value, created_at, expires_at = entry
41
+ if time.time() > expires_at:
42
+ del self._cache[key]
43
+ return NO_VALUE, None
44
+
45
+ return pickle.loads(pickled_value), created_at
46
+
47
+ def _do_set(self, key: str, value: Any, ttl: int) -> None:
48
+ """Set value without locking.
49
+ """
50
+ now = time.time()
51
+ pickled_value = pickle.dumps(value)
52
+ self._cache[key] = (pickled_value, now, now + ttl)
53
+
54
+ def _do_delete(self, key: str) -> None:
55
+ """Delete value without locking.
56
+ """
57
+ self._cache.pop(key, None)
58
+
59
+ def _do_clear(self, pattern: str | None = None) -> int:
60
+ """Clear entries matching pattern without locking.
61
+ """
62
+ if pattern is None:
63
+ count = len(self._cache)
64
+ self._cache.clear()
65
+ return count
66
+
67
+ keys_to_delete = [k for k in self._cache if fnmatch.fnmatch(k, pattern)]
68
+ for key in keys_to_delete:
69
+ del self._cache[key]
70
+ return len(keys_to_delete)
71
+
72
+ def _do_keys(self, pattern: str | None = None) -> list[str]:
73
+ """Get keys matching pattern without locking (returns snapshot).
74
+ """
75
+ now = time.time()
76
+ result = []
77
+ keys_to_delete = []
78
+
79
+ for key, entry in list(self._cache.items()):
80
+ _, _, expires_at = entry
81
+ if now > expires_at:
82
+ keys_to_delete.append(key)
83
+ continue
84
+ if pattern is None or fnmatch.fnmatch(key, pattern):
85
+ result.append(key)
86
+
87
+ for key in keys_to_delete:
88
+ self._cache.pop(key, None)
89
+
90
+ return result
91
+
92
+ # ===== Sync interface =====
93
+
94
+ def get(self, key: str) -> Any:
95
+ """Get value by key. Returns NO_VALUE if not found or expired.
96
+ """
97
+ with self._sync_lock:
98
+ value, _ = self._do_get(key)
99
+ return value
100
+
101
+ def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
102
+ """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
103
+ """
104
+ with self._sync_lock:
105
+ return self._do_get(key)
106
+
107
+ def set(self, key: str, value: Any, ttl: int) -> None:
108
+ """Set value with TTL in seconds.
109
+ """
110
+ with self._sync_lock:
111
+ self._do_set(key, value, ttl)
112
+
113
+ def delete(self, key: str) -> None:
114
+ """Delete value by key.
115
+ """
116
+ with self._sync_lock:
117
+ self._do_delete(key)
118
+
119
+ def clear(self, pattern: str | None = None) -> int:
120
+ """Clear entries matching pattern. Returns count of cleared entries.
121
+ """
122
+ with self._sync_lock:
123
+ return self._do_clear(pattern)
124
+
125
+ def keys(self, pattern: str | None = None) -> Iterator[str]:
126
+ """Iterate over keys matching pattern.
127
+ """
128
+ with self._sync_lock:
129
+ all_keys = self._do_keys(pattern)
130
+ yield from all_keys
131
+
132
+ def count(self, pattern: str | None = None) -> int:
133
+ """Count keys matching pattern.
134
+ """
135
+ with self._sync_lock:
136
+ return len(self._do_keys(pattern))
137
+
138
+ def get_mutex(self, key: str) -> CacheMutex:
139
+ """Get a mutex for dogpile prevention on the given key.
140
+ """
141
+ return ThreadingMutex(f'memory:{key}')
142
+
143
+ # ===== Async interface =====
144
+
145
+ async def aget(self, key: str) -> Any:
146
+ """Async get value by key. Returns NO_VALUE if not found or expired.
147
+ """
148
+ async with self._get_async_lock():
149
+ value, _ = self._do_get(key)
150
+ return value
151
+
152
+ async def aget_with_metadata(self, key: str) -> tuple[Any, float | None]:
153
+ """Async get value and creation timestamp. Returns (NO_VALUE, None) if not found.
154
+ """
155
+ async with self._get_async_lock():
156
+ return self._do_get(key)
157
+
158
+ async def aset(self, key: str, value: Any, ttl: int) -> None:
159
+ """Async set value with TTL in seconds.
160
+ """
161
+ async with self._get_async_lock():
162
+ self._do_set(key, value, ttl)
163
+
164
+ async def adelete(self, key: str) -> None:
165
+ """Async delete value by key.
166
+ """
167
+ async with self._get_async_lock():
168
+ self._do_delete(key)
169
+
170
+ async def aclear(self, pattern: str | None = None) -> int:
171
+ """Async clear entries matching pattern. Returns count of cleared entries.
172
+ """
173
+ async with self._get_async_lock():
174
+ return self._do_clear(pattern)
175
+
176
+ async def akeys(self, pattern: str | None = None) -> AsyncIterator[str]:
177
+ """Async iterate over keys matching pattern.
178
+ """
179
+ async with self._get_async_lock():
180
+ all_keys = self._do_keys(pattern)
181
+
182
+ for key in all_keys:
183
+ yield key
184
+
185
+ async def acount(self, pattern: str | None = None) -> int:
186
+ """Async count keys matching pattern.
187
+ """
188
+ async with self._get_async_lock():
189
+ return len(self._do_keys(pattern))
190
+
191
+ def get_async_mutex(self, key: str) -> AsyncioMutex:
192
+ """Get an async mutex for dogpile prevention on the given key.
193
+ """
194
+ return AsyncioMutex(f'memory:{key}')
195
+
196
+ # ===== Lifecycle =====
197
+
198
+ def close(self) -> None:
199
+ """Close the backend (no-op for memory backend).
200
+ """
201
+
202
+ async def aclose(self) -> None:
203
+ """Async close the backend (no-op for memory backend).
204
+ """
@@ -6,7 +6,8 @@ import time
6
6
  from collections.abc import AsyncIterator, Iterator
7
7
  from typing import TYPE_CHECKING, Any
8
8
 
9
- from . import NO_VALUE, AsyncBackend, Backend
9
+ from ..mutex import AsyncCacheMutex, AsyncRedisMutex, CacheMutex, RedisMutex
10
+ from . import NO_VALUE, Backend
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  import redis
@@ -25,7 +26,7 @@ def _get_redis_module() -> Any:
25
26
  return redis
26
27
  except ImportError as e:
27
28
  raise RuntimeError(
28
- "Redis support requires the 'redis' package. Install with: pip install cache[redis]"
29
+ "Redis support requires the 'redis' package. Install with: pip install cachu[redis]"
29
30
  ) from e
30
31
 
31
32
 
@@ -42,16 +43,6 @@ def _get_async_redis_module() -> Any:
42
43
  ) from e
43
44
 
44
45
 
45
- async def get_async_redis_client(url: str) -> 'aioredis.Redis':
46
- """Create an async Redis client from URL.
47
-
48
- Args:
49
- url: Redis URL (e.g., 'redis://localhost:6379/0')
50
- """
51
- aioredis = _get_async_redis_module()
52
- return aioredis.from_url(url)
53
-
54
-
55
46
  def get_redis_client(url: str) -> 'redis.Redis':
56
47
  """Create a Redis client from URL.
57
48
 
@@ -79,20 +70,32 @@ def _unpack_value(data: bytes) -> tuple[Any, float]:
79
70
 
80
71
 
81
72
  class RedisBackend(Backend):
82
- """Redis cache backend.
73
+ """Unified Redis cache backend with both sync and async interfaces.
83
74
  """
84
75
 
85
- def __init__(self, url: str) -> None:
76
+ def __init__(self, url: str, lock_timeout: float = 10.0) -> None:
86
77
  self._url = url
87
- self._client: redis.Redis | None = None
78
+ self._lock_timeout = lock_timeout
79
+ self._sync_client: redis.Redis | None = None
80
+ self._async_client: aioredis.Redis | None = None
88
81
 
89
82
  @property
90
83
  def client(self) -> 'redis.Redis':
91
- """Lazy-load Redis client.
84
+ """Lazy-load sync Redis client.
85
+ """
86
+ if self._sync_client is None:
87
+ self._sync_client = get_redis_client(self._url)
88
+ return self._sync_client
89
+
90
+ def _get_async_client(self) -> 'aioredis.Redis':
91
+ """Lazy-load async Redis client (from_url is NOT async).
92
92
  """
93
- if self._client is None:
94
- self._client = get_redis_client(self._url)
95
- return self._client
93
+ if self._async_client is None:
94
+ aioredis = _get_async_redis_module()
95
+ self._async_client = aioredis.from_url(self._url)
96
+ return self._async_client
97
+
98
+ # ===== Sync interface =====
96
99
 
97
100
  def get(self, key: str) -> Any:
98
101
  """Get value by key. Returns NO_VALUE if not found.
@@ -148,67 +151,51 @@ class RedisBackend(Backend):
148
151
  """
149
152
  return sum(1 for _ in self.keys(pattern))
150
153
 
151
- def close(self) -> None:
152
- """Close the Redis connection.
154
+ def get_mutex(self, key: str) -> CacheMutex:
155
+ """Get a mutex for dogpile prevention on the given key.
153
156
  """
154
- if self._client is not None:
155
- self._client.close()
156
- self._client = None
157
-
158
-
159
- class AsyncRedisBackend(AsyncBackend):
160
- """Async Redis cache backend using redis.asyncio.
161
- """
157
+ return RedisMutex(self.client, f'lock:{key}', self._lock_timeout)
162
158
 
163
- def __init__(self, url: str) -> None:
164
- self._url = url
165
- self._client: aioredis.Redis | None = None
166
-
167
- async def _get_client(self) -> 'aioredis.Redis':
168
- """Lazy-load async Redis client.
169
- """
170
- if self._client is None:
171
- self._client = await get_async_redis_client(self._url)
172
- return self._client
159
+ # ===== Async interface =====
173
160
 
174
- async def get(self, key: str) -> Any:
175
- """Get value by key. Returns NO_VALUE if not found.
161
+ async def aget(self, key: str) -> Any:
162
+ """Async get value by key. Returns NO_VALUE if not found.
176
163
  """
177
- client = await self._get_client()
164
+ client = self._get_async_client()
178
165
  data = await client.get(key)
179
166
  if data is None:
180
167
  return NO_VALUE
181
168
  value, _ = _unpack_value(data)
182
169
  return value
183
170
 
184
- async def get_with_metadata(self, key: str) -> tuple[Any, float | None]:
185
- """Get value and creation timestamp. Returns (NO_VALUE, None) if not found.
171
+ async def aget_with_metadata(self, key: str) -> tuple[Any, float | None]:
172
+ """Async get value and creation timestamp. Returns (NO_VALUE, None) if not found.
186
173
  """
187
- client = await self._get_client()
174
+ client = self._get_async_client()
188
175
  data = await client.get(key)
189
176
  if data is None:
190
177
  return NO_VALUE, None
191
178
  value, created_at = _unpack_value(data)
192
179
  return value, created_at
193
180
 
194
- async def set(self, key: str, value: Any, ttl: int) -> None:
195
- """Set value with TTL in seconds.
181
+ async def aset(self, key: str, value: Any, ttl: int) -> None:
182
+ """Async set value with TTL in seconds.
196
183
  """
197
- client = await self._get_client()
184
+ client = self._get_async_client()
198
185
  now = time.time()
199
186
  packed = _pack_value(value, now)
200
187
  await client.setex(key, ttl, packed)
201
188
 
202
- async def delete(self, key: str) -> None:
203
- """Delete value by key.
189
+ async def adelete(self, key: str) -> None:
190
+ """Async delete value by key.
204
191
  """
205
- client = await self._get_client()
192
+ client = self._get_async_client()
206
193
  await client.delete(key)
207
194
 
208
- async def clear(self, pattern: str | None = None) -> int:
209
- """Clear entries matching pattern. Returns count of cleared entries.
195
+ async def aclear(self, pattern: str | None = None) -> int:
196
+ """Async clear entries matching pattern. Returns count of cleared entries.
210
197
  """
211
- client = await self._get_client()
198
+ client = self._get_async_client()
212
199
  if pattern is None:
213
200
  pattern = '*'
214
201
 
@@ -218,25 +205,59 @@ class AsyncRedisBackend(AsyncBackend):
218
205
  count += 1
219
206
  return count
220
207
 
221
- async def keys(self, pattern: str | None = None) -> AsyncIterator[str]:
222
- """Iterate over keys matching pattern.
208
+ async def akeys(self, pattern: str | None = None) -> AsyncIterator[str]:
209
+ """Async iterate over keys matching pattern.
223
210
  """
224
- client = await self._get_client()
211
+ client = self._get_async_client()
225
212
  redis_pattern = pattern or '*'
226
213
  async for key in client.scan_iter(match=redis_pattern):
227
214
  yield key.decode() if isinstance(key, bytes) else key
228
215
 
229
- async def count(self, pattern: str | None = None) -> int:
230
- """Count keys matching pattern.
216
+ async def acount(self, pattern: str | None = None) -> int:
217
+ """Async count keys matching pattern.
231
218
  """
232
219
  count = 0
233
- async for _ in self.keys(pattern):
220
+ async for _ in self.akeys(pattern):
234
221
  count += 1
235
222
  return count
236
223
 
237
- async def close(self) -> None:
238
- """Close the Redis connection.
224
+ def get_async_mutex(self, key: str) -> AsyncCacheMutex:
225
+ """Get an async mutex for dogpile prevention on the given key.
226
+ """
227
+ return AsyncRedisMutex(self._get_async_client(), f'lock:{key}', self._lock_timeout)
228
+
229
+ # ===== Lifecycle =====
230
+
231
+ def _close_sync_client(self) -> None:
232
+ """Close sync client if open.
233
+ """
234
+ if self._sync_client is not None:
235
+ client = self._sync_client
236
+ self._sync_client = None
237
+ client.close()
238
+
239
+ def _close_async_client_sync(self) -> None:
240
+ """Forcefully close async client from sync context.
241
+ """
242
+ if self._async_client is not None:
243
+ client = self._async_client
244
+ self._async_client = None
245
+ try:
246
+ client.close()
247
+ except Exception:
248
+ pass
249
+
250
+ def close(self) -> None:
251
+ """Close all backend resources from sync context.
252
+ """
253
+ self._close_sync_client()
254
+ self._close_async_client_sync()
255
+
256
+ async def aclose(self) -> None:
257
+ """Close all backend resources from async context.
239
258
  """
240
- if self._client is not None:
241
- await self._client.close()
242
- self._client = None
259
+ if self._async_client is not None:
260
+ client = self._async_client
261
+ self._async_client = None
262
+ await client.aclose()
263
+ self._close_sync_client()