Skip to content

Commit 88f393c

Browse files
committed
feat(cache): add per-invocation TTL support and improve documentation
- Add per-invocation TTL feature for cached items - Update README with detailed explanations of TTL and per-invocation TTL - Refactor cache.py to support new TTL functionality - Update CHANGELOG with new features and breaking changes
1 parent c81859c commit 88f393c

File tree

3 files changed

+53
-18
lines changed

3 files changed

+53
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
-**New Features:**
88
- Added arguments excluding support for the `RedisFuncCache` class, which makes it possible to cache functions with arguments that cannot be serialized.
9+
- Added support for per-invocation TTL.
910

1011
- 💔 **Breaking Changes:**
1112
- Rename `redis_func_cache.mixins.policies` to `redis_func_cache.mixins.scripts`.

README.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,20 +368,52 @@ Policies that support both clusters and store cache in multiple [Redis][] key pa
368368
369369
The [`RedisFuncCache`][] instance has two arguments to control the maximum size and expiration time of the cache:
370370
371-
- `maxsize`: the maximum number of items that the cache can hold.
371+
- `maxsize`: The maximum number of items the cache can hold.
372372
373373
When the cache reaches its `maxsize`, adding a new item will cause an existing cached item to be removed according to the eviction policy.
374374
375375
> ℹ️ **Note:**\
376376
> For "multiple" policies, each decorated function has its own standalone data structure, so the value represents the maximum size of each individual data structure.
377377
378+
This argument can be set when creating a cache instance:
379+
380+
```python
381+
cache = RedisFuncCache("my-cache-5", LruTPolicy, redis_client, maxsize=100)
382+
```
383+
378384
- `ttl`: The expiration time (in seconds) for the cache data structure.
379385
380-
The cache's [Redis][] data structure will expire and be released after the specified time.
381-
Each time the cache is accessed, the expiration time will be reset.
386+
The entire [Redis][] data structure for the cache will expire and be removed after the specified time.
387+
Each time the cache is accessed, its expiration time is refreshed. Thus, the cache will only be removed if it is not accessed within the specified period.
382388
383389
> ℹ️ **Note:**\
384-
> For "multiple" policies, each decorated function has its own standalone data structure, so the `ttl` value represents the expiration time of each individual data structure. The expiration time will be reset each time the cache is accessed individually.
390+
> For "multiple" policies, each decorated function has its own separate data structure, so the `ttl` value applies to each individual structure. The expiration time is refreshed independently whenever each cache is accessed.
391+
392+
You can set this argument when creating a cache instance:
393+
394+
```python
395+
cache = RedisFuncCache("my-cache-5", LruTPolicy, redis_client, ttl=300)
396+
```
397+
398+
- per-invocation TTL: The expiration time (in seconds) for each cached item, not the entire cache.
399+
400+
You can set this argument when decorating a function:
401+
402+
```python
403+
@cache(ttl=300)
404+
def my_func(x):
405+
...
406+
```
407+
408+
This means that each cached return value for a specific invocation of `my_func` will expire after 300 seconds.
409+
The argument's default value is `None`, which means that the cache item will never expire.
410+
411+
> ⁉️ **Caution:**\
412+
> This expiration relies on [Redis Hashes Field expiration](https://redis.io/docs/latest/develop/data-types/hashes/#field-expiration). Expiration only applies to the hash field (the cached return value), and does **not** reduce the total number of items in the cache when a field expires.
413+
> Typically, the cached return value in the HASH portion is automatically released after expiration. However, the corresponding hash key in the ZSET portion is **not** removed automatically. Instead, it is only "lazily" cleaned up when accessed, or removed by the eviction policy when a new value is added. During this period, the ZSET portion continues to occupy memory, and the reported number of cache items does not decrease.
414+
415+
> ⚠️ **Warning:**\
416+
> This feature requires [Redis][] Open Source version 7.4 or later.
385417

386418
### Complex return types
387419

src/redis_func_cache/cache.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,9 @@ async def aput(
444444
ext_args = ext_args or ()
445445
await script(keys=key_pair, args=chain((maxsize, ttl, hash_, value, field_ttl, encoded_options), ext_args))
446446

447-
def _make_bound(
448-
self,
447+
@classmethod
448+
def make_bound(
449+
cls,
449450
user_func: Callable,
450451
user_args: Tuple[Any, ...],
451452
user_kwds: Dict[str, Any],
@@ -464,13 +465,13 @@ def _make_bound(
464465
bound.arguments = OrderedDict((k, v) for k, v in bound.arguments.items() if k not in excludes)
465466
return bound
466467

467-
def _before_get(
468+
def prepare(
468469
self,
469470
user_function: Callable,
470471
user_args: Tuple[Any, ...],
471472
user_kwds: Dict[str, Any],
472473
bound: Optional[BoundArguments] = None,
473-
):
474+
) -> Tuple[Tuple[KeyT, KeyT], KeyT, Iterable[EncodableT]]:
474475
if bound is None:
475476
args, kwds = user_args, user_kwds
476477
else:
@@ -488,9 +489,9 @@ def exec(
488489
serialize_func: Optional[SerializerT] = None,
489490
deserialize_func: Optional[DeserializerT] = None,
490491
bound: Optional[BoundArguments] = None,
491-
field_ttl: Optional[int] = None,
492+
field_ttl: int = 0,
492493
**options,
493-
):
494+
) -> Any:
494495
"""Execute the given user function with the provided arguments.
495496
496497
Args:
@@ -517,7 +518,7 @@ def exec(
517518
script_0, script_1 = self.policy.lua_scripts
518519
if not (isinstance(script_0, redis.commands.core.Script) and isinstance(script_1, redis.commands.core.Script)):
519520
raise TypeError("Can not eval redis lua script in asynchronous mode on a synchronous redis client")
520-
keys, hash_value, ext_args = self._before_get(user_function, user_args, user_kwds, bound)
521+
keys, hash_value, ext_args = self.prepare(user_function, user_args, user_kwds, bound)
521522
cached_return_value = self.get(script_0, keys, hash_value, self.ttl, options, ext_args)
522523
if cached_return_value is not None:
523524
return self.deserialize(cached_return_value, deserialize_func)
@@ -544,17 +545,17 @@ async def aexec(
544545
serialize_func: Optional[SerializerT] = None,
545546
deserialize_func: Optional[DeserializerT] = None,
546547
bound: Optional[BoundArguments] = None,
547-
field_ttl: Optional[int] = None,
548+
field_ttl: int = 0,
548549
**options,
549-
):
550+
) -> Any:
550551
"""Asynchronous version of :meth:`.exec`"""
551552
script_0, script_1 = self.policy.lua_scripts
552553
if not (
553554
isinstance(script_0, redis.commands.core.AsyncScript)
554555
and isinstance(script_1, redis.commands.core.AsyncScript)
555556
):
556557
raise TypeError("Can not eval redis lua script in synchronous mode on an asynchronous redis client")
557-
keys, hash_value, ext_args = self._before_get(user_function, user_args, user_kwds, bound)
558+
keys, hash_value, ext_args = self.prepare(user_function, user_args, user_kwds, bound)
558559
cached = await self.aget(script_0, keys, hash_value, self.ttl, options, ext_args)
559560
if cached is not None:
560561
return self.deserialize(cached, deserialize_func)
@@ -675,33 +676,34 @@ def my_func(a, b):
675676
serialize_func, deserialize_func = self.__serializers__[serializer]
676677
elif serializer is not None:
677678
serialize_func, deserialize_func = serializer
679+
field_ttl = 0 if ttl is None else int(ttl)
678680

679681
def decorator(user_func: CallableTV) -> CallableTV:
680682
@wraps(user_func)
681683
def wrapper(*user_args, **user_kwargs):
682-
bound = self._make_bound(user_func, user_args, user_kwargs, excludes, excludes_positional)
684+
bound = self.make_bound(user_func, user_args, user_kwargs, excludes, excludes_positional)
683685
return self.exec(
684686
user_func,
685687
user_args,
686688
user_kwargs,
687689
serialize_func,
688690
deserialize_func,
689691
bound,
690-
ttl,
692+
field_ttl,
691693
**options,
692694
)
693695

694696
@wraps(user_func)
695697
async def awrapper(*user_args, **user_kwargs):
696-
bound = self._make_bound(user_func, user_args, user_kwargs, excludes, excludes_positional)
698+
bound = self.make_bound(user_func, user_args, user_kwargs, excludes, excludes_positional)
697699
return await self.aexec(
698700
user_func,
699701
user_args,
700702
user_kwargs,
701703
serialize_func,
702704
deserialize_func,
703705
bound,
704-
ttl,
706+
field_ttl,
705707
**options,
706708
)
707709

0 commit comments

Comments
 (0)