gcache 1.2.0__tar.gz → 2.0.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.
- {gcache-1.2.0 → gcache-2.0.0}/PKG-INFO +20 -5
- {gcache-1.2.0 → gcache-2.0.0}/README.md +18 -3
- {gcache-1.2.0 → gcache-2.0.0}/pyproject.toml +2 -2
- {gcache-1.2.0 → gcache-2.0.0}/src/gcache/__init__.py +2 -1
- gcache-2.0.0/src/gcache/_internal/__init__.py +1 -0
- gcache-2.0.0/src/gcache/_internal/cache_interface.py +69 -0
- gcache-2.0.0/src/gcache/_internal/constants.py +18 -0
- {gcache-1.2.0/src/gcache → gcache-2.0.0/src/gcache/_internal}/event_loop_thread.py +18 -4
- gcache-2.0.0/src/gcache/_internal/local_cache.py +71 -0
- gcache-2.0.0/src/gcache/_internal/metrics.py +86 -0
- gcache-2.0.0/src/gcache/_internal/noop_cache.py +22 -0
- gcache-2.0.0/src/gcache/_internal/redis_cache.py +205 -0
- gcache-2.0.0/src/gcache/_internal/state.py +35 -0
- gcache-2.0.0/src/gcache/_internal/wrappers.py +173 -0
- gcache-2.0.0/src/gcache/config.py +215 -0
- gcache-2.0.0/src/gcache/exceptions.py +52 -0
- gcache-2.0.0/src/gcache/gcache.py +363 -0
- gcache-1.2.0/src/gcache/base.py +0 -1076
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gcache
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Fine grained caching.
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Galileo Technologies Inc.
|
|
@@ -21,7 +21,7 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
21
21
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Dist: cachetools (>=5.5.2)
|
|
24
|
-
Requires-Dist: prometheus-client (>=0.
|
|
24
|
+
Requires-Dist: prometheus-client (>=0.22.1,<0.23.0)
|
|
25
25
|
Requires-Dist: pydantic (>=2.11.4,<3.0.0)
|
|
26
26
|
Requires-Dist: redis (>=5.0.0)
|
|
27
27
|
Requires-Dist: types-cachetools (>=6.2.0.20250827,<7.0.0.0)
|
|
@@ -136,6 +136,24 @@ Caching doesn't happen automatically—you control when it's active:
|
|
|
136
136
|
|
|
137
137
|
- **Dynamic config** — The config provider runs on each request, so you can adjust TTLs or ramp percentages without redeploying.
|
|
138
138
|
|
|
139
|
+
### Why Explicit `enable()`?
|
|
140
|
+
|
|
141
|
+
GCache requires you to explicitly enable caching with `with gcache.enable():`. This is intentional.
|
|
142
|
+
|
|
143
|
+
Caching in write paths can cause subtle bugs—a stale read might get cached right before a write, leading to inconsistent data. By requiring explicit opt-in, GCache forces you to consciously decide where caching is safe:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Read path — caching is safe
|
|
147
|
+
with gcache.enable():
|
|
148
|
+
user = await get_user(user_id)
|
|
149
|
+
|
|
150
|
+
# Write path — no caching, function runs normally
|
|
151
|
+
await update_user(user_id, new_data)
|
|
152
|
+
await gcache.ainvalidate("user_id", user_id)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This design prevents accidental caching in dangerous places.
|
|
156
|
+
|
|
139
157
|
## Runtime Configuration
|
|
140
158
|
|
|
141
159
|
For dynamic control, provide a config provider when creating GCache. This lets you adjust caching behavior without redeploying:
|
|
@@ -230,7 +248,6 @@ from gcache import RedisConfig
|
|
|
230
248
|
|
|
231
249
|
gcache = GCache(
|
|
232
250
|
GCacheConfig(
|
|
233
|
-
cache_config_provider=config_provider,
|
|
234
251
|
redis_config=RedisConfig(
|
|
235
252
|
host="redis.example.com",
|
|
236
253
|
port=6379,
|
|
@@ -261,7 +278,6 @@ def make_redis_factory():
|
|
|
261
278
|
|
|
262
279
|
gcache = GCache(
|
|
263
280
|
GCacheConfig(
|
|
264
|
-
cache_config_provider=config_provider,
|
|
265
281
|
redis_client_factory=make_redis_factory(),
|
|
266
282
|
)
|
|
267
283
|
)
|
|
@@ -337,7 +353,6 @@ You can add a prefix to avoid collisions:
|
|
|
337
353
|
|
|
338
354
|
```python
|
|
339
355
|
GCacheConfig(
|
|
340
|
-
cache_config_provider=config_provider,
|
|
341
356
|
metrics_prefix="myapp_", # Metrics become myapp_gcache_request_counter, etc.
|
|
342
357
|
)
|
|
343
358
|
```
|
|
@@ -104,6 +104,24 @@ Caching doesn't happen automatically—you control when it's active:
|
|
|
104
104
|
|
|
105
105
|
- **Dynamic config** — The config provider runs on each request, so you can adjust TTLs or ramp percentages without redeploying.
|
|
106
106
|
|
|
107
|
+
### Why Explicit `enable()`?
|
|
108
|
+
|
|
109
|
+
GCache requires you to explicitly enable caching with `with gcache.enable():`. This is intentional.
|
|
110
|
+
|
|
111
|
+
Caching in write paths can cause subtle bugs—a stale read might get cached right before a write, leading to inconsistent data. By requiring explicit opt-in, GCache forces you to consciously decide where caching is safe:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# Read path — caching is safe
|
|
115
|
+
with gcache.enable():
|
|
116
|
+
user = await get_user(user_id)
|
|
117
|
+
|
|
118
|
+
# Write path — no caching, function runs normally
|
|
119
|
+
await update_user(user_id, new_data)
|
|
120
|
+
await gcache.ainvalidate("user_id", user_id)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
This design prevents accidental caching in dangerous places.
|
|
124
|
+
|
|
107
125
|
## Runtime Configuration
|
|
108
126
|
|
|
109
127
|
For dynamic control, provide a config provider when creating GCache. This lets you adjust caching behavior without redeploying:
|
|
@@ -198,7 +216,6 @@ from gcache import RedisConfig
|
|
|
198
216
|
|
|
199
217
|
gcache = GCache(
|
|
200
218
|
GCacheConfig(
|
|
201
|
-
cache_config_provider=config_provider,
|
|
202
219
|
redis_config=RedisConfig(
|
|
203
220
|
host="redis.example.com",
|
|
204
221
|
port=6379,
|
|
@@ -229,7 +246,6 @@ def make_redis_factory():
|
|
|
229
246
|
|
|
230
247
|
gcache = GCache(
|
|
231
248
|
GCacheConfig(
|
|
232
|
-
cache_config_provider=config_provider,
|
|
233
249
|
redis_client_factory=make_redis_factory(),
|
|
234
250
|
)
|
|
235
251
|
)
|
|
@@ -305,7 +321,6 @@ You can add a prefix to avoid collisions:
|
|
|
305
321
|
|
|
306
322
|
```python
|
|
307
323
|
GCacheConfig(
|
|
308
|
-
cache_config_provider=config_provider,
|
|
309
324
|
metrics_prefix="myapp_", # Metrics become myapp_gcache_request_counter, etc.
|
|
310
325
|
)
|
|
311
326
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "gcache"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Fine grained caching."
|
|
5
5
|
authors = [{ name = "Galileo Technologies Inc.", email = "team@rungalileo.io" }]
|
|
6
6
|
readme = "README.md"
|
|
@@ -31,7 +31,7 @@ repository = "https://github.com/rungalileo/gcache"
|
|
|
31
31
|
[tool.poetry.dependencies]
|
|
32
32
|
python = "^3.10"
|
|
33
33
|
pydantic = "^2.11.4"
|
|
34
|
-
prometheus-client = "^0.
|
|
34
|
+
prometheus-client = "^0.22.1"
|
|
35
35
|
cachetools = ">=5.5.2"
|
|
36
36
|
types-cachetools = "^6.2.0.20250827"
|
|
37
37
|
redis = ">=5.0.0"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
from .
|
|
1
|
+
from gcache.config import CacheConfigProvider, CacheLayer, GCacheConfig, GCacheKey, GCacheKeyConfig, RedisConfig
|
|
2
|
+
from gcache.gcache import GCache
|
|
2
3
|
|
|
3
4
|
__all__ = ["CacheConfigProvider", "CacheLayer", "GCache", "GCacheConfig", "GCacheKey", "GCacheKeyConfig", "RedisConfig"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Internal implementation modules - not part of public API
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from gcache.config import CacheConfigProvider, CacheLayer, GCacheKey, GCacheKeyConfig
|
|
6
|
+
|
|
7
|
+
#: Async callable that fetches the actual value on cache miss.
|
|
8
|
+
#: Invoked by cache implementations when the requested key is not found or is stale.
|
|
9
|
+
Fallback = Callable[..., Awaitable[Any]]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CacheInterface(ABC):
|
|
13
|
+
def __init__(self, cache_config_provider: CacheConfigProvider):
|
|
14
|
+
self.config_provider = cache_config_provider
|
|
15
|
+
|
|
16
|
+
async def _resolve_config(self, key: GCacheKey) -> GCacheKeyConfig | None:
|
|
17
|
+
"""
|
|
18
|
+
Resolve the cache config for a key.
|
|
19
|
+
|
|
20
|
+
First tries the config provider, then falls back to the key's default_config.
|
|
21
|
+
Returns None if neither provides a config.
|
|
22
|
+
"""
|
|
23
|
+
config = await self.config_provider(key)
|
|
24
|
+
if config is None:
|
|
25
|
+
config = key.default_config
|
|
26
|
+
return config
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def get(self, key: GCacheKey, fallback: Fallback) -> Any:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def put(self, key: GCacheKey, value: Any) -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def delete(self, key: GCacheKey) -> bool:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
async def invalidate(self, key_type: str, id: str, future_buffer_ms: int) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Invalidate all cache entries matching key_type and id.
|
|
43
|
+
|
|
44
|
+
Sets a watermark timestamp so that any cached value created before
|
|
45
|
+
(now + future_buffer_ms) is considered stale on subsequent reads.
|
|
46
|
+
|
|
47
|
+
:param key_type: The entity type (e.g., 'user', 'project') matching the
|
|
48
|
+
key_type used in @cached decorators.
|
|
49
|
+
:param id: The entity identifier to invalidate.
|
|
50
|
+
:param future_buffer_ms: Extends invalidation window into the future.
|
|
51
|
+
Useful to handle race conditions where a read starts before a write
|
|
52
|
+
completes but finishes after, preventing caching of stale data.
|
|
53
|
+
"""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def layer(self) -> CacheLayer:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def flushall(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Remove all entries from this cache layer.
|
|
63
|
+
|
|
64
|
+
Used primarily for testing to reset cache state between tests.
|
|
65
|
+
Default implementation is a no-op; subclasses should override if
|
|
66
|
+
they support flushing (e.g., LocalCache clears its TTLCache dict,
|
|
67
|
+
RedisCache calls FLUSHALL on Redis).
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Cache sizes
|
|
2
|
+
# Default max entries per use case to prevent unbounded memory growth.
|
|
3
|
+
LOCAL_CACHE_MAX_SIZE = 10_000
|
|
4
|
+
|
|
5
|
+
# Thresholds
|
|
6
|
+
# Threshold above which pickling runs in a thread to avoid blocking the event loop.
|
|
7
|
+
ASYNC_PICKLE_THRESHOLD_BYTES = 50_000
|
|
8
|
+
|
|
9
|
+
# TTLs (seconds)
|
|
10
|
+
# Watermark TTL must be longer than any invalidatable cache's TTL to ensure
|
|
11
|
+
# invalidation works correctly. 4 hours is a heuristic that covers most use cases.
|
|
12
|
+
# If your cache TTLs exceed 4 hours, consider making this configurable.
|
|
13
|
+
WATERMARK_TTL_SECONDS = 3600 * 4 # 4 hours
|
|
14
|
+
|
|
15
|
+
# Thread pool
|
|
16
|
+
# Default thread pool size for running async operations from sync code.
|
|
17
|
+
# Balances concurrency for I/O-bound Redis operations without excessive resource usage.
|
|
18
|
+
EVENT_LOOP_THREAD_POOL_SIZE = 16
|
|
@@ -7,7 +7,21 @@ from concurrent.futures import Future
|
|
|
7
7
|
from logging import getLogger
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
from gcache._internal.constants import EVENT_LOOP_THREAD_POOL_SIZE
|
|
11
|
+
|
|
12
|
+
# uvloop is optional - provides better performance on Linux/macOS but
|
|
13
|
+
# doesn't work on Windows or PyPy. Fall back to standard asyncio if unavailable.
|
|
14
|
+
try:
|
|
15
|
+
import uvloop
|
|
16
|
+
|
|
17
|
+
def _new_event_loop() -> asyncio.AbstractEventLoop:
|
|
18
|
+
return uvloop.new_event_loop()
|
|
19
|
+
|
|
20
|
+
except ImportError:
|
|
21
|
+
|
|
22
|
+
def _new_event_loop() -> asyncio.AbstractEventLoop:
|
|
23
|
+
return asyncio.new_event_loop()
|
|
24
|
+
|
|
11
25
|
|
|
12
26
|
logger = getLogger(__name__)
|
|
13
27
|
|
|
@@ -31,7 +45,7 @@ class EventLoopThread(EventLoopThreadInterface, threading.Thread):
|
|
|
31
45
|
def __init__(self, name: str = "EventLoopThread", daemon: bool = True) -> None:
|
|
32
46
|
super().__init__(name=name)
|
|
33
47
|
self.daemon = daemon
|
|
34
|
-
self.loop =
|
|
48
|
+
self.loop = _new_event_loop()
|
|
35
49
|
|
|
36
50
|
def run(self) -> None:
|
|
37
51
|
# Set the event loop for this thread.
|
|
@@ -68,12 +82,12 @@ class EventLoopThread(EventLoopThreadInterface, threading.Thread):
|
|
|
68
82
|
|
|
69
83
|
class EventLoopThreadPool(EventLoopThreadInterface):
|
|
70
84
|
"""
|
|
71
|
-
Manage collection of EventLoopThread instances and also
|
|
85
|
+
Manage collection of EventLoopThread instances and also initialize them lazily.
|
|
72
86
|
|
|
73
87
|
Lazy initialization is important when running in forked processes.
|
|
74
88
|
"""
|
|
75
89
|
|
|
76
|
-
def __init__(self, name: str = "EventLoopThreadPool", num_threads: int =
|
|
90
|
+
def __init__(self, name: str = "EventLoopThreadPool", num_threads: int = EVENT_LOOP_THREAD_POOL_SIZE) -> None:
|
|
77
91
|
self.name = name
|
|
78
92
|
self.num_threads = num_threads
|
|
79
93
|
self.threads: list[EventLoopThread] | None = None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from cachetools import TTLCache
|
|
5
|
+
|
|
6
|
+
from gcache._internal.cache_interface import CacheInterface, Fallback
|
|
7
|
+
from gcache._internal.constants import LOCAL_CACHE_MAX_SIZE
|
|
8
|
+
from gcache._internal.state import _GLOBAL_GCACHE_STATE
|
|
9
|
+
from gcache.config import CacheConfigProvider, CacheLayer, GCacheKey
|
|
10
|
+
from gcache.exceptions import MissingKeyConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LocalCache(CacheInterface):
|
|
14
|
+
"""
|
|
15
|
+
In-memory cache layer using TTLCache from cachetools.
|
|
16
|
+
|
|
17
|
+
Maintains a separate TTLCache instance per use_case, each with a configurable
|
|
18
|
+
TTL and a max size of LOCAL_CACHE_MAX_SIZE entries. This is the first layer
|
|
19
|
+
in the cache chain, checked before Redis.
|
|
20
|
+
|
|
21
|
+
Note: LocalCache does not support invalidation (watermarks). If you need
|
|
22
|
+
invalidation support, rely on the Redis layer with track_for_invalidation=True.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, cache_config_provider: CacheConfigProvider):
|
|
26
|
+
super().__init__(cache_config_provider)
|
|
27
|
+
self.caches: dict[str, TTLCache] = {} # use_case -> TTLCache instance
|
|
28
|
+
self.lock = asyncio.Lock() # Protects cache creation
|
|
29
|
+
|
|
30
|
+
async def _get_ttl_cache(self, key: GCacheKey) -> TTLCache:
|
|
31
|
+
cache = self.caches.get(key.use_case, None)
|
|
32
|
+
if cache is None:
|
|
33
|
+
config = await self._resolve_config(key)
|
|
34
|
+
if config is None:
|
|
35
|
+
raise MissingKeyConfig(key.use_case)
|
|
36
|
+
|
|
37
|
+
async with self.lock:
|
|
38
|
+
# See if cache was already created by another worker.
|
|
39
|
+
cache = self.caches.get(key.use_case, None)
|
|
40
|
+
if cache is None:
|
|
41
|
+
self.caches[key.use_case] = cache = TTLCache(
|
|
42
|
+
maxsize=LOCAL_CACHE_MAX_SIZE, ttl=config.ttl_sec[self.layer()]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return cache
|
|
46
|
+
|
|
47
|
+
async def get(self, key: GCacheKey, fallback: Fallback) -> Any:
|
|
48
|
+
_GLOBAL_GCACHE_STATE.logger.debug("Calling local cache")
|
|
49
|
+
cache = await self._get_ttl_cache(key)
|
|
50
|
+
|
|
51
|
+
if key not in cache:
|
|
52
|
+
await self.put(key, await fallback())
|
|
53
|
+
|
|
54
|
+
return cache[key]
|
|
55
|
+
|
|
56
|
+
async def put(self, key: GCacheKey, value: Any) -> None:
|
|
57
|
+
(await self._get_ttl_cache(key))[key] = value
|
|
58
|
+
|
|
59
|
+
async def delete(self, key: GCacheKey) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
(await self._get_ttl_cache(key)).pop(key)
|
|
62
|
+
except KeyError:
|
|
63
|
+
return False
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def layer(self) -> CacheLayer:
|
|
67
|
+
return CacheLayer.LOCAL
|
|
68
|
+
|
|
69
|
+
async def flushall(self) -> None:
|
|
70
|
+
async with self.lock:
|
|
71
|
+
self.caches.clear()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from prometheus_client import Counter, Histogram
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GCacheMetrics:
|
|
5
|
+
"""Centralized Prometheus metrics for GCache."""
|
|
6
|
+
|
|
7
|
+
_initialized: bool = False
|
|
8
|
+
|
|
9
|
+
# Counters
|
|
10
|
+
DISABLED_COUNTER: Counter
|
|
11
|
+
MISS_COUNTER: Counter
|
|
12
|
+
REQUEST_COUNTER: Counter
|
|
13
|
+
ERROR_COUNTER: Counter
|
|
14
|
+
INVALIDATION_COUNTER: Counter
|
|
15
|
+
|
|
16
|
+
# Histograms
|
|
17
|
+
GET_TIMER: Histogram
|
|
18
|
+
FALLBACK_TIMER: Histogram
|
|
19
|
+
SERIALIZATION_TIMER: Histogram
|
|
20
|
+
SIZE_HISTOGRAM: Histogram
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def initialize(cls, prefix: str = "") -> None:
|
|
24
|
+
"""Initialize all metrics with the given prefix. Only initializes once."""
|
|
25
|
+
if cls._initialized:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
cls.DISABLED_COUNTER = Counter(
|
|
29
|
+
name=prefix + "gcache_disabled_counter",
|
|
30
|
+
labelnames=["use_case", "key_type", "layer", "reason"],
|
|
31
|
+
documentation="Cache disabled counter",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
cls.MISS_COUNTER = Counter(
|
|
35
|
+
name=prefix + "gcache_miss_counter",
|
|
36
|
+
labelnames=["use_case", "key_type", "layer"],
|
|
37
|
+
documentation="Cache miss counter",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
cls.REQUEST_COUNTER = Counter(
|
|
41
|
+
name=prefix + "gcache_request_counter",
|
|
42
|
+
labelnames=["use_case", "key_type", "layer"],
|
|
43
|
+
documentation="Cache request counter",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
cls.ERROR_COUNTER = Counter(
|
|
47
|
+
name=prefix + "gcache_error_counter",
|
|
48
|
+
labelnames=["use_case", "key_type", "layer", "error", "in_fallback"],
|
|
49
|
+
documentation="Cache error counter",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
cls.INVALIDATION_COUNTER = Counter(
|
|
53
|
+
name=prefix + "gcache_invalidation_counter",
|
|
54
|
+
labelnames=["key_type", "layer"],
|
|
55
|
+
documentation="Cache invalidation counter",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
cls.GET_TIMER = Histogram(
|
|
59
|
+
name=prefix + "gcache_get_timer",
|
|
60
|
+
labelnames=["use_case", "key_type", "layer"],
|
|
61
|
+
documentation="Cache get timer",
|
|
62
|
+
buckets=[0.001] + list(Histogram.DEFAULT_BUCKETS),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
cls.FALLBACK_TIMER = Histogram(
|
|
66
|
+
name=prefix + "gcache_fallback_timer",
|
|
67
|
+
labelnames=["use_case", "key_type", "layer"],
|
|
68
|
+
documentation="Fallback timer",
|
|
69
|
+
buckets=[0.001] + list(Histogram.DEFAULT_BUCKETS),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
cls.SERIALIZATION_TIMER = Histogram(
|
|
73
|
+
name=prefix + "gcache_serialization_timer",
|
|
74
|
+
labelnames=["use_case", "key_type", "layer", "operation"],
|
|
75
|
+
documentation="Cache serialization timer",
|
|
76
|
+
buckets=[0.001] + list(Histogram.DEFAULT_BUCKETS),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
cls.SIZE_HISTOGRAM = Histogram(
|
|
80
|
+
name=prefix + "gcache_size_histogram",
|
|
81
|
+
labelnames=["use_case", "key_type", "layer"],
|
|
82
|
+
documentation="Cache size histogram",
|
|
83
|
+
buckets=[100, 1000, 10_000, 100_000, 1_000_000, 10_000_000],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
cls._initialized = True
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from gcache._internal.cache_interface import CacheInterface, Fallback
|
|
4
|
+
from gcache.config import CacheLayer, GCacheKey
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class NoopCache(CacheInterface):
|
|
8
|
+
"""
|
|
9
|
+
NOOP Cache that does nothing but invoke fallback on get.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
async def get(self, key: GCacheKey, fallback: Fallback) -> Any:
|
|
13
|
+
return await fallback()
|
|
14
|
+
|
|
15
|
+
async def put(self, key: GCacheKey, value: Any) -> None:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
async def delete(self, key: GCacheKey) -> bool:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
def layer(self) -> CacheLayer:
|
|
22
|
+
return CacheLayer.NOOP
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pickle
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from concurrent.futures.thread import ThreadPoolExecutor
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from redis.asyncio import Redis, RedisCluster
|
|
11
|
+
|
|
12
|
+
from gcache._internal.cache_interface import CacheInterface, Fallback
|
|
13
|
+
from gcache._internal.constants import ASYNC_PICKLE_THRESHOLD_BYTES, WATERMARK_TTL_SECONDS
|
|
14
|
+
from gcache._internal.metrics import GCacheMetrics
|
|
15
|
+
from gcache._internal.state import _GLOBAL_GCACHE_STATE
|
|
16
|
+
from gcache.config import CacheConfigProvider, CacheLayer, GCacheKey, RedisConfig
|
|
17
|
+
from gcache.exceptions import MissingKeyConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class RedisValue:
|
|
22
|
+
"""
|
|
23
|
+
Wrapper around cached payload that includes creation timestamp.
|
|
24
|
+
|
|
25
|
+
The timestamp enables cache invalidation: when a watermark is set for a key,
|
|
26
|
+
any cached value with created_at_ms <= watermark is considered stale.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
created_at_ms: int # Unix timestamp in milliseconds when this value was cached
|
|
30
|
+
payload: Any # The actual cached data (may be serialized if Serializer is used)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_default_redis_client_factory(
|
|
34
|
+
config: RedisConfig,
|
|
35
|
+
) -> Callable[[], Redis | RedisCluster]:
|
|
36
|
+
"""
|
|
37
|
+
Create a default Redis client factory function from a RedisConfig.
|
|
38
|
+
|
|
39
|
+
This factory creates a new Redis client each time it's called. Thread-local
|
|
40
|
+
caching is handled by RedisCache, so the factory doesn't need to manage it.
|
|
41
|
+
|
|
42
|
+
:param config: RedisConfig containing URL, cluster flag, and redis-py options
|
|
43
|
+
:return: Factory function that creates a Redis client
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def factory() -> Redis | RedisCluster:
|
|
47
|
+
options: dict[str, int | bool | str] = config.redis_py_options
|
|
48
|
+
if config.cluster:
|
|
49
|
+
return RedisCluster.from_url(config.url, **options)
|
|
50
|
+
else:
|
|
51
|
+
return Redis.from_url(config.url, **options) # type: ignore[arg-type]
|
|
52
|
+
|
|
53
|
+
return factory
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RedisCache(CacheInterface):
|
|
57
|
+
_executor = ThreadPoolExecutor()
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
cache_config_provider: CacheConfigProvider,
|
|
62
|
+
client_factory: Callable[[], Redis | RedisCluster],
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Initialize RedisCache.
|
|
66
|
+
|
|
67
|
+
:param cache_config_provider: Provider for cache configuration
|
|
68
|
+
:param client_factory: Factory function to create Redis clients.
|
|
69
|
+
The factory should return a new Redis client when called. Thread-local
|
|
70
|
+
caching is handled internally by RedisCache - the factory will only be
|
|
71
|
+
called once per thread, and the resulting client will be reused for
|
|
72
|
+
subsequent operations on that thread.
|
|
73
|
+
"""
|
|
74
|
+
super().__init__(cache_config_provider)
|
|
75
|
+
self._client_factory = client_factory
|
|
76
|
+
# Thread-local storage is required because async redis-py clients maintain
|
|
77
|
+
# internal state (connection pool, pending requests) bound to a specific event loop.
|
|
78
|
+
# Since gcache runs sync cached functions in EventLoopThread workers (each with its
|
|
79
|
+
# own event loop), sharing a client across threads causes "attached to a different
|
|
80
|
+
# event loop" RuntimeError. One client per thread ensures correct event loop binding.
|
|
81
|
+
self._thread_local = threading.local()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def client(self) -> Redis | RedisCluster:
|
|
85
|
+
"""
|
|
86
|
+
Get a Redis client for the current thread.
|
|
87
|
+
|
|
88
|
+
The client is created once per thread using the factory and cached
|
|
89
|
+
in thread-local storage for reuse.
|
|
90
|
+
"""
|
|
91
|
+
if not hasattr(self._thread_local, "client"):
|
|
92
|
+
self._thread_local.client = self._client_factory()
|
|
93
|
+
return self._thread_local.client
|
|
94
|
+
|
|
95
|
+
async def _exec_fallback(
|
|
96
|
+
self,
|
|
97
|
+
key: GCacheKey,
|
|
98
|
+
watermark_ms: int | None,
|
|
99
|
+
fallback: Fallback,
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""
|
|
102
|
+
Execute the fallback function, optionally cache the result, and return it.
|
|
103
|
+
|
|
104
|
+
The result is stored in cache unless there's an active invalidation window
|
|
105
|
+
(watermark_ms is in the future). This prevents caching potentially stale data
|
|
106
|
+
that was fetched during an invalidation period.
|
|
107
|
+
|
|
108
|
+
:param key: Cache key for storing the result.
|
|
109
|
+
:param watermark_ms: Invalidation watermark timestamp in milliseconds, or None.
|
|
110
|
+
If set and greater than current time, the result is not cached.
|
|
111
|
+
:param fallback: Async function that fetches the actual value.
|
|
112
|
+
:return: The value returned by the fallback function.
|
|
113
|
+
"""
|
|
114
|
+
val = await fallback()
|
|
115
|
+
if watermark_ms is None or watermark_ms < time.time() * 1e3:
|
|
116
|
+
await self.put(key, val)
|
|
117
|
+
return val
|
|
118
|
+
|
|
119
|
+
async def invalidate(self, key_type: str, id: str, future_buffer_ms: int) -> None:
|
|
120
|
+
GCacheMetrics.INVALIDATION_COUNTER.labels(key_type, self.layer().name).inc()
|
|
121
|
+
|
|
122
|
+
key = "{" + _GLOBAL_GCACHE_STATE.urn_prefix + ":" + key_type + ":" + id + "}#watermark"
|
|
123
|
+
exp_ms = int(time.time() * 1000 + future_buffer_ms)
|
|
124
|
+
await self.client.setex(key, WATERMARK_TTL_SECONDS, exp_ms)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
async def _async_pickle_loads(data: bytes) -> Any:
|
|
128
|
+
loop = asyncio.get_event_loop()
|
|
129
|
+
return await loop.run_in_executor(RedisCache._executor, pickle.loads, data)
|
|
130
|
+
|
|
131
|
+
async def get(self, key: GCacheKey, fallback: Fallback) -> Any:
|
|
132
|
+
_GLOBAL_GCACHE_STATE.logger.debug("Calling Redis Cache")
|
|
133
|
+
|
|
134
|
+
watermark_ms = None
|
|
135
|
+
if key.invalidation_tracking:
|
|
136
|
+
vals = await self.client.mget(key.urn, key.prefix + "#watermark")
|
|
137
|
+
val_pickle = vals[0]
|
|
138
|
+
watermark_ms = vals[1]
|
|
139
|
+
if watermark_ms is not None:
|
|
140
|
+
watermark_ms = float(watermark_ms)
|
|
141
|
+
else:
|
|
142
|
+
val_pickle = await self.client.get(key.urn)
|
|
143
|
+
if val_pickle is not None:
|
|
144
|
+
start_sec = time.monotonic()
|
|
145
|
+
|
|
146
|
+
deserialized_value: RedisValue = (
|
|
147
|
+
pickle.loads(val_pickle)
|
|
148
|
+
if len(val_pickle) < ASYNC_PICKLE_THRESHOLD_BYTES
|
|
149
|
+
else await RedisCache._async_pickle_loads(val_pickle)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Load payload using custom serializer if present.
|
|
153
|
+
payload = deserialized_value.payload
|
|
154
|
+
if key.serializer is not None:
|
|
155
|
+
payload = await key.serializer.load(payload)
|
|
156
|
+
|
|
157
|
+
(
|
|
158
|
+
GCacheMetrics.SERIALIZATION_TIMER.labels(key.use_case, key.key_type, self.layer().name, "load").observe(
|
|
159
|
+
time.monotonic() - start_sec
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Check if cache val is expired.
|
|
164
|
+
if watermark_ms is not None:
|
|
165
|
+
watermark_ms = int(watermark_ms)
|
|
166
|
+
if watermark_ms >= deserialized_value.created_at_ms:
|
|
167
|
+
return await self._exec_fallback(key, watermark_ms, fallback)
|
|
168
|
+
return payload
|
|
169
|
+
else:
|
|
170
|
+
return await self._exec_fallback(key, watermark_ms, fallback)
|
|
171
|
+
|
|
172
|
+
async def put(self, key: GCacheKey, value: Any) -> None:
|
|
173
|
+
config = await self._resolve_config(key)
|
|
174
|
+
if config is None:
|
|
175
|
+
raise MissingKeyConfig(key.use_case)
|
|
176
|
+
|
|
177
|
+
current_time_ms = int(time.time() * 1000)
|
|
178
|
+
|
|
179
|
+
start_time = time.monotonic()
|
|
180
|
+
serialized_value = value if key.serializer is None else await key.serializer.dump(value)
|
|
181
|
+
|
|
182
|
+
val_pickle = pickle.dumps(
|
|
183
|
+
RedisValue(created_at_ms=current_time_ms, payload=serialized_value), protocol=pickle.HIGHEST_PROTOCOL
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
GCacheMetrics.SERIALIZATION_TIMER.labels(key.use_case, key.key_type, self.layer().name, "dump").observe(
|
|
187
|
+
time.monotonic() - start_time
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
GCacheMetrics.SIZE_HISTOGRAM.labels(key.use_case, key.key_type, self.layer().name).observe(len(val_pickle))
|
|
191
|
+
|
|
192
|
+
ttl = config.ttl_sec.get(self.layer(), None)
|
|
193
|
+
if ttl is None:
|
|
194
|
+
raise MissingKeyConfig(key.use_case)
|
|
195
|
+
|
|
196
|
+
await self.client.setex(key.urn, ttl, val_pickle)
|
|
197
|
+
|
|
198
|
+
async def delete(self, key: GCacheKey) -> bool:
|
|
199
|
+
return (await self.client.delete(key.urn)) > 0
|
|
200
|
+
|
|
201
|
+
def layer(self) -> CacheLayer:
|
|
202
|
+
return CacheLayer.REMOTE
|
|
203
|
+
|
|
204
|
+
async def flushall(self) -> None:
|
|
205
|
+
return await self.client.flushall()
|