Skip to content

Commit ba1ff51

Browse files
committed
Merge branch 'feature/fixed_ttl_window' into develop
2 parents a14f2ef + 35695dd commit ba1ff51

File tree

15 files changed

+736
-176
lines changed

15 files changed

+736
-176
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- Added arguments excluding support for the `RedisFuncCache` class, which makes it possible to cache functions with arguments that cannot be serialized.
99
- Added support for per-invocation TTL.
1010
- `RdisFuncCache.disabled()` provides a scope in which the cache is disabled temporarily.
11+
- Added support for controlling cache TTL update behavior with `update_ttl` parameter.
1112

1213
- 💔 **Breaking Changes:**
1314
- Rename `redis_func_cache.mixins.policies` to `redis_func_cache.mixins.scripts`.

README.md

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,47 @@ Other serialization libraries such as [bson][], [simplejson](https://pypi.org/pr
464464
> The [`pickle`][] module is highly powerful but poses a significant security risk because it can execute arbitrary code during deserialization. Use it with extreme caution, especially when handling data from untrusted sources.
465465
> For best practices, it is recommended to cache functions that return simple, [JSON][]-serializable data. If you need to serialize more complex data structures than those supported by [JSON][], consider using safer alternatives such as [bson][], [msgpack][], or [yaml][].
466466

467+
### TTL Update Behavior
468+
469+
By default, accessing cached data updates the expiration time (TTL) of the cache data structures. This behavior is referred to as "sliding TTL". However, you can control this behavior using the `update_ttl` parameter.
470+
471+
There are two TTL update modes:
472+
473+
- **Sliding TTL** (`update_ttl=True`, default): Each cache access (both read and write) extends the life of the cache data structures.
474+
- **Fixed TTL** (`update_ttl=False`): The expiration time is set only when the cache data structures are first created, and subsequent accesses do not extend their life.
475+
476+
You can set the `update_ttl` parameter at the cache instance level:
477+
478+
```python
479+
# Sliding TTL (default behavior)
480+
sliding_cache = RedisFuncCache("sliding-cache", LruTPolicy, redis_client, update_ttl=True)
481+
482+
# Fixed TTL
483+
fixed_cache = RedisFuncCache("fixed-cache", LruTPolicy, redis_client, update_ttl=False)
484+
```
485+
486+
Or override it at the function level:
487+
488+
```python
489+
cache = RedisFuncCache("my-cache", LruTPolicy, redis_client)
490+
491+
# Override to use fixed TTL for this specific function
492+
@cache(update_ttl=False)
493+
def my_func(x):
494+
...
495+
496+
# Override to use sliding TTL for this specific function
497+
@cache(update_ttl=True)
498+
def another_func(x):
499+
...
500+
```
501+
502+
The `update_ttl` parameter controls the behavior of the cache data structures (sorted set and hash map) as a whole, not individual cached items. It is independent of the per-item TTL (set via the `ttl` parameter in the decorator), which controls the expiration of individual cached function results.
503+
504+
> 💡 **Tip:** \
505+
> Use fixed TTL when you want predictable cache expiration behavior, regardless of how often cached functions are accessed.
506+
> Use sliding TTL when you want frequently accessed cached data to remain in cache longer.
507+
467508
## Advanced Usage
468509

469510
### Custom result serializer
@@ -657,7 +698,7 @@ The serializer and decoder are defined in the `__hash_config__` attribute of the
657698
658699
This configuration can be illustrated as follows:
659700
660-
```mermaid
701+
```
661702
flowchart TD
662703
A[Start] --> B{Is f callable?}
663704
B -->|No| C[Throw TypeError]
@@ -988,7 +1029,7 @@ pre-commit install
9881029
9891030
### Module structure
9901031
991-
```mermaid
1032+
```
9921033
graph TD
9931034
A[RedisFuncCache] --> B[AbstractPolicy]
9941035
A --> C[Serializer]
@@ -1016,7 +1057,7 @@ graph TD
10161057
10171058
Core class:
10181059
1019-
```mermaid
1060+
```
10201061
classDiagram
10211062
class RedisFuncCache {
10221063
-client: RedisClientTV
@@ -1059,7 +1100,7 @@ classDiagram
10591100
10601101
Strategy pattern and mixins:
10611102
1062-
```mermaid
1103+
```
10631104
classDiagram
10641105
class LruPolicy {
10651106
__key__ = "lru"
@@ -1091,7 +1132,7 @@ classDiagram
10911132
10921133
Cluster and multiple-keys support
10931134
1094-
```mermaid
1135+
```
10951136
classDiagram
10961137
class BaseClusterSinglePolicy {
10971138
+calc_keys(f, args, kwds) -> Tuple[str, str]
@@ -1113,7 +1154,7 @@ classDiagram
11131154
11141155
Decorator and proxy:
11151156
1116-
```mermaid
1157+
```
11171158
classDiagram
11181159
class RedisFuncCache {
11191160
+__call__(user_function) -> CallableTV
@@ -1130,7 +1171,7 @@ classDiagram
11301171
11311172
Weak reference:
11321173
1133-
```mermaid
1174+
```
11341175
classDiagram
11351176
class AbstractPolicy {
11361177
-_cache: CallableProxyType[RedisFuncCache]

src/redis_func_cache/cache.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def __init__(
9898
client: Union[RedisClientTV, Callable[[], RedisClientTV]],
9999
maxsize: int = DEFAULT_MAXSIZE,
100100
ttl: int = DEFAULT_TTL,
101+
update_ttl: bool = True,
101102
prefix: str = DEFAULT_PREFIX,
102103
serializer: SerializerSetterValueT = "json",
103104
):
@@ -138,6 +139,12 @@ def __init__(
138139
Zero or negative values means no set ttl.
139140
Assigned to property :attr:`ttl`.
140141
142+
update_ttl: Whether to update the TTL of cached items when they are accessed.
143+
144+
If not provided, the default is ``True``.
145+
When ``True``, accessing a cached item will reset its TTL.
146+
When ``False``, accessing a cached item will not update its TTL.
147+
141148
prefix: The prefix for cache keys.
142149
143150
If not provided, the default is :data:`.DEFAULT_PREFIX`.
@@ -186,6 +193,7 @@ def my_deserializer(data):
186193
self.prefix = prefix
187194
self.maxsize = maxsize
188195
self.ttl = ttl
196+
self.update_ttl = update_ttl
189197
self._policy_type = policy
190198
self._policy_instance: Optional[AbstractPolicy] = None
191199
self._redis_instance: Optional[RedisClientTV] = None
@@ -368,6 +376,7 @@ def get(
368376
script: redis.commands.core.Script,
369377
key_pair: Tuple[KeyT, KeyT],
370378
hash_value: KeyT,
379+
update_ttl: bool,
371380
ttl: int,
372381
options: Optional[Mapping[str, Any]] = None,
373382
ext_args: Optional[Iterable[EncodableT]] = None,
@@ -387,22 +396,23 @@ def get(
387396
"""
388397
encoded_options = json.dumps(options or {}, ensure_ascii=False).encode()
389398
ext_args = ext_args or ()
390-
return script(keys=key_pair, args=chain((ttl, hash_value, encoded_options), ext_args))
399+
return script(keys=key_pair, args=chain((int(update_ttl), ttl, hash_value, encoded_options), ext_args))
391400

392401
@classmethod
393402
async def aget(
394403
cls,
395404
script: redis.commands.core.AsyncScript,
396405
key_pair: Tuple[KeyT, KeyT],
397406
hash_: KeyT,
407+
update_ttl: bool,
398408
ttl: int,
399409
options: Optional[Mapping[str, Any]] = None,
400410
ext_args: Optional[Iterable[EncodableT]] = None,
401411
) -> Optional[EncodedT]:
402412
"""Async version of :meth:`get`"""
403413
encoded_options = json.dumps(options or {}, ensure_ascii=False).encode()
404414
ext_args = ext_args or ()
405-
return await script(keys=key_pair, args=chain((ttl, hash_, encoded_options), ext_args))
415+
return await script(keys=key_pair, args=chain((int(update_ttl), ttl, hash_, encoded_options), ext_args))
406416

407417
@classmethod
408418
def put(
@@ -412,6 +422,7 @@ def put(
412422
hash_value: KeyT,
413423
value: EncodableT,
414424
maxsize: int,
425+
update_ttl: bool,
415426
ttl: int,
416427
field_ttl: int = 0,
417428
options: Optional[Mapping[str, Any]] = None,
@@ -433,7 +444,10 @@ def put(
433444
"""
434445
encoded_options = json.dumps(options or {}, ensure_ascii=False).encode()
435446
ext_args = ext_args or ()
436-
script(keys=key_pair, args=chain((maxsize, ttl, hash_value, value, field_ttl, encoded_options), ext_args))
447+
script(
448+
keys=key_pair,
449+
args=chain((maxsize, int(update_ttl), ttl, hash_value, value, field_ttl, encoded_options), ext_args),
450+
)
437451

438452
@classmethod
439453
async def aput(
@@ -443,6 +457,7 @@ async def aput(
443457
hash_: KeyT,
444458
value: EncodableT,
445459
maxsize: int,
460+
update_ttl: bool,
446461
ttl: int,
447462
field_ttl: int = 0,
448463
options: Optional[Mapping[str, Any]] = None,
@@ -451,7 +466,10 @@ async def aput(
451466
"""Async version of :meth:`put`"""
452467
encoded_options = json.dumps(options or {}, ensure_ascii=False).encode()
453468
ext_args = ext_args or ()
454-
await script(keys=key_pair, args=chain((maxsize, ttl, hash_, value, field_ttl, encoded_options), ext_args))
469+
await script(
470+
keys=key_pair,
471+
args=chain((maxsize, int(update_ttl), ttl, hash_, value, field_ttl, encoded_options), ext_args),
472+
)
455473

456474
@classmethod
457475
def make_bound(
@@ -499,6 +517,7 @@ def exec(
499517
deserialize_func: Optional[DeserializerT] = None,
500518
bound: Optional[BoundArguments] = None,
501519
field_ttl: int = 0,
520+
update_ttl: bool = True,
502521
**options,
503522
) -> Any:
504523
"""Execute the given user function with the provided arguments.
@@ -515,6 +534,10 @@ def exec(
515534
- If it is provided, the policy will only use the filtered arguments to calculate the cache key and hash value.
516535
- If it is not provided, the policy will use all arguments to calculate the cache key and hash value.
517536
537+
field_ttl: Time-to-live (in seconds) for the cached field.
538+
539+
update_ttl: Whether to update the TTL of cached items when they are accessed.
540+
518541
options: Additional options passed from :meth:`decorate`'s `**kwargs`.
519542
520543
Returns:
@@ -530,7 +553,7 @@ def exec(
530553
if not (isinstance(script_0, redis.commands.core.Script) and isinstance(script_1, redis.commands.core.Script)):
531554
raise TypeError("Can not eval redis lua script in asynchronous mode on a synchronous redis client")
532555
keys, hash_value, ext_args = self.prepare(user_function, user_args, user_kwds, bound)
533-
cached_return_value = self.get(script_0, keys, hash_value, self.ttl, options, ext_args)
556+
cached_return_value = self.get(script_0, keys, hash_value, update_ttl, self.ttl, options, ext_args)
534557
if cached_return_value is not None:
535558
return self.deserialize(cached_return_value, deserialize_func)
536559
user_retval = user_function(*user_args, **user_kwds)
@@ -541,6 +564,7 @@ def exec(
541564
hash_value,
542565
user_retval_serialized,
543566
self.maxsize,
567+
update_ttl,
544568
self.ttl,
545569
0 if field_ttl is None else field_ttl,
546570
options,
@@ -557,9 +581,29 @@ async def aexec(
557581
deserialize_func: Optional[DeserializerT] = None,
558582
bound: Optional[BoundArguments] = None,
559583
field_ttl: int = 0,
584+
update_ttl: bool = True,
560585
**options,
561586
) -> Any:
562-
"""Asynchronous version of :meth:`.exec`"""
587+
"""Asynchronous version of :meth:`.exec`
588+
589+
Args:
590+
user_function: The user function to execute.
591+
user_args: Positional arguments to pass to the user function.
592+
user_kwds: Keyword arguments to pass to the user function.
593+
serialize_func: Custom serializer passed from :meth:`decorate`.
594+
deserialize_func: Custom deserializer passed from :meth:`decorate`.
595+
596+
bound: Filtered bound arguments which will be used by the policy of the cache.
597+
598+
- If it is provided, the policy will only use the filtered arguments to calculate the cache key and hash value.
599+
- If it is not provided, the policy will use all arguments to calculate the cache key and hash value.
600+
601+
field_ttl: Time-to-live (in seconds) for the cached field.
602+
603+
update_ttl: Whether to update the TTL of cached items when they are accessed.
604+
605+
options: Additional options passed from :meth:`decorate`'s `**kwargs`.
606+
"""
563607
if self._disabled.get():
564608
return await user_function(*user_args, **user_kwds)
565609
script_0, script_1 = self.policy.lua_scripts
@@ -569,7 +613,7 @@ async def aexec(
569613
):
570614
raise TypeError("Can not eval redis lua script in synchronous mode on an asynchronous redis client")
571615
keys, hash_value, ext_args = self.prepare(user_function, user_args, user_kwds, bound)
572-
cached = await self.aget(script_0, keys, hash_value, self.ttl, options, ext_args)
616+
cached = await self.aget(script_0, keys, hash_value, update_ttl, self.ttl, options, ext_args)
573617
if cached is not None:
574618
return self.deserialize(cached, deserialize_func)
575619
user_retval = await user_function(*user_args, **user_kwds)
@@ -580,6 +624,7 @@ async def aexec(
580624
hash_value,
581625
user_retval_serialized,
582626
self.maxsize,
627+
update_ttl,
583628
self.ttl,
584629
0 if field_ttl is None else field_ttl,
585630
options,
@@ -594,6 +639,7 @@ def decorate(
594639
*,
595640
serializer: Optional[SerializerSetterValueT] = None,
596641
ttl: Optional[int] = None,
642+
update_ttl: Optional[bool] = None,
597643
excludes: Optional[Sequence[str]] = None,
598644
excludes_positional: Optional[Sequence[int]] = None,
599645
**options,
@@ -623,6 +669,12 @@ def decorate(
623669
Warning:
624670
Only available in Redis Open Source version above 7.4
625671
672+
update_ttl: Whether to update the TTL of cached items when they are accessed.
673+
674+
If not provided, the default is the value set in the cache instance.
675+
When ``True``, accessing a cached item will reset its TTL.
676+
When ``False``, accessing a cached item will not update its TTL.
677+
626678
excludes: Optional sequence of parameter names specifying keyword arguments to exclude from cache key generation.
627679
628680
Example: `excludes=["token"]`
@@ -690,6 +742,7 @@ def my_func(a, b):
690742
elif serializer is not None:
691743
serialize_func, deserialize_func = serializer
692744
field_ttl = 0 if ttl is None else int(ttl)
745+
effective_update_ttl = self.update_ttl if update_ttl is None else update_ttl
693746

694747
def decorator(user_func: CallableTV) -> CallableTV:
695748
@wraps(user_func)
@@ -703,6 +756,7 @@ def wrapper(*user_args, **user_kwargs):
703756
deserialize_func,
704757
bound,
705758
field_ttl,
759+
effective_update_ttl,
706760
**options,
707761
)
708762

@@ -717,6 +771,7 @@ async def awrapper(*user_args, **user_kwargs):
717771
deserialize_func,
718772
bound,
719773
field_ttl,
774+
effective_update_ttl,
720775
**options,
721776
)
722777

src/redis_func_cache/lua/fifo_get.lua

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
21
--[[
32
FIFO (First-In-First-Out) cache get operation using zset order.
43
KEYS[1]: Redis sorted set key for cache hashes (score = order)
54
KEYS[2]: Redis hash key for cache values
6-
ARGV[1]: TTL for both keys (number, seconds)
7-
ARGV[2]: hash key to retrieve
5+
ARGV[1]: update ttl flag (1 for update ttl, 0 for fixed ttl)
6+
ARGV[2]: TTL for both keys (number, seconds)
7+
ARGV[3]: hash key to retrieve
88
Returns: value if found, otherwise nil. Cleans up stale entries.
99
]]
1010
local zset_key = KEYS[1]
1111
local hmap_key = KEYS[2]
1212

13-
local ttl = ARGV[1]
14-
local hash = ARGV[2]
13+
local update_ttl_flag = ARGV[1]
14+
local ttl = ARGV[2]
15+
local hash = ARGV[3]
1516

16-
-- Set TTL if specified
17-
if tonumber(ttl) > 0 then
17+
-- Set TTL if specified and update_ttl_flag is set
18+
if tonumber(ttl) > 0 and update_ttl_flag == "1" then
1819
redis.call('EXPIRE', zset_key, ttl)
1920
redis.call('EXPIRE', hmap_key, ttl)
2021
end
@@ -30,3 +31,5 @@ elseif rnk then
3031
elseif val then
3132
redis.call('HDEL', hmap_key, hash) -- remove stale hash value
3233
end
34+
35+
return nil

0 commit comments

Comments
 (0)