gcache 1.2.1__tar.gz → 2.0.2__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.1 → gcache-2.0.2}/PKG-INFO +65 -12
- {gcache-1.2.1 → gcache-2.0.2}/README.md +64 -11
- {gcache-1.2.1 → gcache-2.0.2}/pyproject.toml +1 -1
- {gcache-1.2.1 → gcache-2.0.2}/src/gcache/__init__.py +2 -1
- gcache-2.0.2/src/gcache/_internal/__init__.py +1 -0
- gcache-2.0.2/src/gcache/_internal/cache_interface.py +69 -0
- gcache-2.0.2/src/gcache/_internal/constants.py +18 -0
- {gcache-1.2.1/src/gcache → gcache-2.0.2/src/gcache/_internal}/event_loop_thread.py +18 -4
- gcache-2.0.2/src/gcache/_internal/local_cache.py +71 -0
- gcache-2.0.2/src/gcache/_internal/metrics.py +86 -0
- gcache-2.0.2/src/gcache/_internal/noop_cache.py +22 -0
- gcache-2.0.2/src/gcache/_internal/redis_cache.py +205 -0
- gcache-2.0.2/src/gcache/_internal/state.py +35 -0
- gcache-2.0.2/src/gcache/_internal/wrappers.py +173 -0
- gcache-2.0.2/src/gcache/config.py +215 -0
- gcache-2.0.2/src/gcache/exceptions.py +52 -0
- gcache-2.0.2/src/gcache/gcache.py +363 -0
- gcache-1.2.1/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.2
|
|
4
4
|
Summary: Fine grained caching.
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Galileo Technologies Inc.
|
|
@@ -35,6 +35,7 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
[](https://badge.fury.io/py/gcache)
|
|
36
36
|
[](https://opensource.org/licenses/MIT)
|
|
37
37
|
[](https://www.python.org/downloads/)
|
|
38
|
+
[](https://codecov.io/gh/rungalileo/gcache)
|
|
38
39
|
|
|
39
40
|
A caching library built for moving fast without breaking things. GCache lets you rapidly add new caching use cases while maintaining structure and runtime control guardrails—so you can ramp up gradually, kill a bad cache instantly, and have full observability into what's cached across your system.
|
|
40
41
|
|
|
@@ -67,6 +68,7 @@ gcache = GCache(GCacheConfig())
|
|
|
67
68
|
@gcache.cached(
|
|
68
69
|
key_type="user_id",
|
|
69
70
|
id_arg="user_id",
|
|
71
|
+
use_case="GetUser",
|
|
70
72
|
default_config=GCacheKeyConfig(
|
|
71
73
|
ttl_sec={CacheLayer.LOCAL: 60, CacheLayer.REMOTE: 300},
|
|
72
74
|
ramp={CacheLayer.LOCAL: 100, CacheLayer.REMOTE: 100},
|
|
@@ -77,7 +79,7 @@ async def get_user(user_id: str) -> dict:
|
|
|
77
79
|
|
|
78
80
|
# Use it — caching only happens inside enable() blocks
|
|
79
81
|
with gcache.enable():
|
|
80
|
-
user = await get_user("123") #
|
|
82
|
+
user = await get_user("123") # Cache key: urn:gcache:user_id:123#GetUser
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
That's it. The function works normally outside `enable()` blocks, and caches results inside them.
|
|
@@ -136,6 +138,24 @@ Caching doesn't happen automatically—you control when it's active:
|
|
|
136
138
|
|
|
137
139
|
- **Dynamic config** — The config provider runs on each request, so you can adjust TTLs or ramp percentages without redeploying.
|
|
138
140
|
|
|
141
|
+
### Why Explicit `enable()`?
|
|
142
|
+
|
|
143
|
+
GCache requires you to explicitly enable caching with `with gcache.enable():`. This is intentional.
|
|
144
|
+
|
|
145
|
+
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:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
# Read path — caching is safe
|
|
149
|
+
with gcache.enable():
|
|
150
|
+
user = await get_user(user_id)
|
|
151
|
+
|
|
152
|
+
# Write path — no caching, function runs normally
|
|
153
|
+
await update_user(user_id, new_data)
|
|
154
|
+
await gcache.ainvalidate("user_id", user_id)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
This design prevents accidental caching in dangerous places.
|
|
158
|
+
|
|
139
159
|
## Runtime Configuration
|
|
140
160
|
|
|
141
161
|
For dynamic control, provide a config provider when creating GCache. This lets you adjust caching behavior without redeploying:
|
|
@@ -183,17 +203,49 @@ async def get_user_profile(user_id: str) -> dict:
|
|
|
183
203
|
|
|
184
204
|
### Working with Complex Arguments
|
|
185
205
|
|
|
186
|
-
|
|
206
|
+
Options for mapping function arguments to cache keys.
|
|
207
|
+
|
|
208
|
+
#### `id_arg` (required)
|
|
209
|
+
|
|
210
|
+
Specifies which argument contains the entity ID for the cache key.
|
|
211
|
+
|
|
212
|
+
**String form** — use when the argument itself is the ID:
|
|
213
|
+
```python
|
|
214
|
+
id_arg="user_id" # user_id argument is the ID
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**Tuple form** — use when the ID needs to be extracted from an object:
|
|
218
|
+
```python
|
|
219
|
+
id_arg=("user", lambda u: u.id) # Extract ID from User object
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### `arg_adapters`
|
|
223
|
+
|
|
224
|
+
Converts complex arguments to strings for the cache key. Only needed for non-primitive types.
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
arg_adapters={
|
|
228
|
+
"filters": lambda f: f.to_cache_key(), # Complex object
|
|
229
|
+
"page": str, # Simple conversion
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
#### `ignore_args`
|
|
234
|
+
|
|
235
|
+
Excludes arguments that don't affect the cached result.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
ignore_args=["db_session", "logger"]
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
#### Example
|
|
187
242
|
|
|
188
243
|
```python
|
|
189
244
|
@gcache.cached(
|
|
190
245
|
key_type="user_id",
|
|
191
|
-
id_arg=("user", lambda u: u.id),
|
|
192
|
-
arg_adapters={
|
|
193
|
-
|
|
194
|
-
"page": str, # Simple conversion
|
|
195
|
-
},
|
|
196
|
-
ignore_args=["db_session", "logger"], # Don't include these in cache key
|
|
246
|
+
id_arg=("user", lambda u: u.id),
|
|
247
|
+
arg_adapters={"filters": lambda f: f.to_cache_key()},
|
|
248
|
+
ignore_args=["db_session", "logger"],
|
|
197
249
|
)
|
|
198
250
|
async def search_user_posts(
|
|
199
251
|
user: User,
|
|
@@ -203,8 +255,12 @@ async def search_user_posts(
|
|
|
203
255
|
logger: Logger,
|
|
204
256
|
) -> list[Post]:
|
|
205
257
|
...
|
|
258
|
+
|
|
259
|
+
# Cache key: urn:gcache:user_id:123?filters=active&page=2#SearchUserPosts
|
|
206
260
|
```
|
|
207
261
|
|
|
262
|
+
The `id_arg` becomes `:123`, `arg_adapters` produce `?filters=active&page=2`, and `ignore_args` are excluded.
|
|
263
|
+
|
|
208
264
|
### Sync Functions Work Too
|
|
209
265
|
|
|
210
266
|
```python
|
|
@@ -230,7 +286,6 @@ from gcache import RedisConfig
|
|
|
230
286
|
|
|
231
287
|
gcache = GCache(
|
|
232
288
|
GCacheConfig(
|
|
233
|
-
cache_config_provider=config_provider,
|
|
234
289
|
redis_config=RedisConfig(
|
|
235
290
|
host="redis.example.com",
|
|
236
291
|
port=6379,
|
|
@@ -261,7 +316,6 @@ def make_redis_factory():
|
|
|
261
316
|
|
|
262
317
|
gcache = GCache(
|
|
263
318
|
GCacheConfig(
|
|
264
|
-
cache_config_provider=config_provider,
|
|
265
319
|
redis_client_factory=make_redis_factory(),
|
|
266
320
|
)
|
|
267
321
|
)
|
|
@@ -337,7 +391,6 @@ You can add a prefix to avoid collisions:
|
|
|
337
391
|
|
|
338
392
|
```python
|
|
339
393
|
GCacheConfig(
|
|
340
|
-
cache_config_provider=config_provider,
|
|
341
394
|
metrics_prefix="myapp_", # Metrics become myapp_gcache_request_counter, etc.
|
|
342
395
|
)
|
|
343
396
|
```
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
[](https://badge.fury.io/py/gcache)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://codecov.io/gh/rungalileo/gcache)
|
|
6
7
|
|
|
7
8
|
A caching library built for moving fast without breaking things. GCache lets you rapidly add new caching use cases while maintaining structure and runtime control guardrails—so you can ramp up gradually, kill a bad cache instantly, and have full observability into what's cached across your system.
|
|
8
9
|
|
|
@@ -35,6 +36,7 @@ gcache = GCache(GCacheConfig())
|
|
|
35
36
|
@gcache.cached(
|
|
36
37
|
key_type="user_id",
|
|
37
38
|
id_arg="user_id",
|
|
39
|
+
use_case="GetUser",
|
|
38
40
|
default_config=GCacheKeyConfig(
|
|
39
41
|
ttl_sec={CacheLayer.LOCAL: 60, CacheLayer.REMOTE: 300},
|
|
40
42
|
ramp={CacheLayer.LOCAL: 100, CacheLayer.REMOTE: 100},
|
|
@@ -45,7 +47,7 @@ async def get_user(user_id: str) -> dict:
|
|
|
45
47
|
|
|
46
48
|
# Use it — caching only happens inside enable() blocks
|
|
47
49
|
with gcache.enable():
|
|
48
|
-
user = await get_user("123") #
|
|
50
|
+
user = await get_user("123") # Cache key: urn:gcache:user_id:123#GetUser
|
|
49
51
|
```
|
|
50
52
|
|
|
51
53
|
That's it. The function works normally outside `enable()` blocks, and caches results inside them.
|
|
@@ -104,6 +106,24 @@ Caching doesn't happen automatically—you control when it's active:
|
|
|
104
106
|
|
|
105
107
|
- **Dynamic config** — The config provider runs on each request, so you can adjust TTLs or ramp percentages without redeploying.
|
|
106
108
|
|
|
109
|
+
### Why Explicit `enable()`?
|
|
110
|
+
|
|
111
|
+
GCache requires you to explicitly enable caching with `with gcache.enable():`. This is intentional.
|
|
112
|
+
|
|
113
|
+
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:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
# Read path — caching is safe
|
|
117
|
+
with gcache.enable():
|
|
118
|
+
user = await get_user(user_id)
|
|
119
|
+
|
|
120
|
+
# Write path — no caching, function runs normally
|
|
121
|
+
await update_user(user_id, new_data)
|
|
122
|
+
await gcache.ainvalidate("user_id", user_id)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This design prevents accidental caching in dangerous places.
|
|
126
|
+
|
|
107
127
|
## Runtime Configuration
|
|
108
128
|
|
|
109
129
|
For dynamic control, provide a config provider when creating GCache. This lets you adjust caching behavior without redeploying:
|
|
@@ -151,17 +171,49 @@ async def get_user_profile(user_id: str) -> dict:
|
|
|
151
171
|
|
|
152
172
|
### Working with Complex Arguments
|
|
153
173
|
|
|
154
|
-
|
|
174
|
+
Options for mapping function arguments to cache keys.
|
|
175
|
+
|
|
176
|
+
#### `id_arg` (required)
|
|
177
|
+
|
|
178
|
+
Specifies which argument contains the entity ID for the cache key.
|
|
179
|
+
|
|
180
|
+
**String form** — use when the argument itself is the ID:
|
|
181
|
+
```python
|
|
182
|
+
id_arg="user_id" # user_id argument is the ID
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Tuple form** — use when the ID needs to be extracted from an object:
|
|
186
|
+
```python
|
|
187
|
+
id_arg=("user", lambda u: u.id) # Extract ID from User object
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### `arg_adapters`
|
|
191
|
+
|
|
192
|
+
Converts complex arguments to strings for the cache key. Only needed for non-primitive types.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
arg_adapters={
|
|
196
|
+
"filters": lambda f: f.to_cache_key(), # Complex object
|
|
197
|
+
"page": str, # Simple conversion
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
#### `ignore_args`
|
|
202
|
+
|
|
203
|
+
Excludes arguments that don't affect the cached result.
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
ignore_args=["db_session", "logger"]
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Example
|
|
155
210
|
|
|
156
211
|
```python
|
|
157
212
|
@gcache.cached(
|
|
158
213
|
key_type="user_id",
|
|
159
|
-
id_arg=("user", lambda u: u.id),
|
|
160
|
-
arg_adapters={
|
|
161
|
-
|
|
162
|
-
"page": str, # Simple conversion
|
|
163
|
-
},
|
|
164
|
-
ignore_args=["db_session", "logger"], # Don't include these in cache key
|
|
214
|
+
id_arg=("user", lambda u: u.id),
|
|
215
|
+
arg_adapters={"filters": lambda f: f.to_cache_key()},
|
|
216
|
+
ignore_args=["db_session", "logger"],
|
|
165
217
|
)
|
|
166
218
|
async def search_user_posts(
|
|
167
219
|
user: User,
|
|
@@ -171,8 +223,12 @@ async def search_user_posts(
|
|
|
171
223
|
logger: Logger,
|
|
172
224
|
) -> list[Post]:
|
|
173
225
|
...
|
|
226
|
+
|
|
227
|
+
# Cache key: urn:gcache:user_id:123?filters=active&page=2#SearchUserPosts
|
|
174
228
|
```
|
|
175
229
|
|
|
230
|
+
The `id_arg` becomes `:123`, `arg_adapters` produce `?filters=active&page=2`, and `ignore_args` are excluded.
|
|
231
|
+
|
|
176
232
|
### Sync Functions Work Too
|
|
177
233
|
|
|
178
234
|
```python
|
|
@@ -198,7 +254,6 @@ from gcache import RedisConfig
|
|
|
198
254
|
|
|
199
255
|
gcache = GCache(
|
|
200
256
|
GCacheConfig(
|
|
201
|
-
cache_config_provider=config_provider,
|
|
202
257
|
redis_config=RedisConfig(
|
|
203
258
|
host="redis.example.com",
|
|
204
259
|
port=6379,
|
|
@@ -229,7 +284,6 @@ def make_redis_factory():
|
|
|
229
284
|
|
|
230
285
|
gcache = GCache(
|
|
231
286
|
GCacheConfig(
|
|
232
|
-
cache_config_provider=config_provider,
|
|
233
287
|
redis_client_factory=make_redis_factory(),
|
|
234
288
|
)
|
|
235
289
|
)
|
|
@@ -305,7 +359,6 @@ You can add a prefix to avoid collisions:
|
|
|
305
359
|
|
|
306
360
|
```python
|
|
307
361
|
GCacheConfig(
|
|
308
|
-
cache_config_provider=config_provider,
|
|
309
362
|
metrics_prefix="myapp_", # Metrics become myapp_gcache_request_counter, etc.
|
|
310
363
|
)
|
|
311
364
|
```
|
|
@@ -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
|