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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gcache
3
- Version: 1.2.0
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.23.1,<0.24.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 = "1.2.0"
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.23.1"
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 .base import CacheConfigProvider, CacheLayer, GCache, GCacheConfig, GCacheKey, GCacheKeyConfig, RedisConfig
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 uvloop
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 = uvloop.new_event_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 initailize them lazily.
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 = 16) -> None:
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()