Implementing TTL Cache in Python (2)

Series - Implementing TTL Cache in Python

Continuing from our last article, we’ll delve deeper into implementing a cache with Time-to-Live (TTL).
Let’s quickly recap what we covered previously.

  1. We learned how to equip functions with cache capabilities using lru_cache.
  2. We implemented the get_ttl_hash function, which updates return values at specified intervals.
  3. We were able to give functions TTL caching by:
    1. Enhancing them with the caching functionality of lru_cache.
    2. Adding a dummy argument.
    3. Inputting the return value of get_ttl_hash to the dummy argument.

Our goal in this article is to create a decorator that automates this process.

Let’s move on to implementing a TTL cache decorator.

First off, let’s briefly explain what decorators are.
Simply put, a decorator is a function that takes another function as an input and returns a new function.

Example

The following two examples get the same output:

  1. Using the lru_cache decorator:

    from functools import lru_cache
    
    
    @lru_cache
    def fibonacci(n):
        ...
    
    print(fibonacci(10))
    
  2. Directly calling and using lru_cache as a function:

    from functools import lru_cache
    
    
    def fibonacci(n):
        ...
    
    print(lru_cache(fibonacci)(10))
    

First, let’s organize what we want to achieve.
Start with the end in mind.

End Goal

By adding @ttl_cache(ttl_seconds=60) at the beginning of a function,
you equip that function with a cache feature that lasts for 60 seconds.

@ttl_cache(ttl_seconds=60)
def myfunc(a, b):
    ...
  • Once executed, the computed result is stored as a cache.
  • Once the cache expiration period (60 seconds in the above example) elapses,
    the value is recalculated and the cache is updated upon the next execution.

The ttl_cache essentially becomes a function that:

  1. Takes an expiration time (ttl_seconds) as input and returns a decorator with the following functionality.
  2. Takes a function as input and returns a modified function with TTL caching capabilities.

So, we’re essentially implementing a function that returns a decorator.

Given the expiration duration ttl_seconds as an argument, the function returning the decorator looks like this:

def ttl_cache(ttl_seconds=3600): 
    def ttl_cache_deco(func):
        ...
    return ttl_cache_deco

Next, we’ll define the inner ttl_cache_deco decorator.

Now, we’ll define a decorator that, when given a function, returns the function with TTL caching capabilities.

This can be achieved using the following steps:

  1. Create a function with added caching capabilities and a dummy argument.
  2. Compute get_ttl_hash(ttl_seconds) and use it as an input for the dummy argument in the function.
def ttl_cache_deco(func):
    """Returns a function with time-to-live (TTL) caching capabilities."""
    # Function with caching capability and dummy argument
    @lru_cache(maxsize=None)
    def cached_dummy_func(*args, ttl_dummy, **kwargs):
        del ttl_dummy  # Remove the dummy argument
        return func(*args, **kwargs)

    # Function to input the hash value into the dummy argument
    def ttl_cached_func(*args, **kwargs):
        hash = get_ttl_hash(ttl_seconds)
        return cached_dummy_func(*args, ttl_dummy=hash, **kwargs)

    return ttl_cached_func
To Preserve Function Metadata

When applying the above decorator to a function, as evident from the code,
the original function gets transformed into the inner ttl_cached_func.

In this transformation, we lose the metadata (arguments information, docstrings, …) of the original function.
To prevent this, we use the functools.wraps decorator on the function returned by the decorator.
(Refer to: functools.wraps)

from functools import lru_cache, wraps
...
def ttl_cache_deco(func):
    ...
    @wraps(func)
    def ttl_cached_func(*args, **kwargs):
        ...

By applying the @wraps decorator this way, we ensure the conversion of the function without losing its original metadata.

With that, our decorator that adds a time-to-live cache functionality is complete.
Here’s a recap of the completed ttl_cache decorator implementation.

import datetime
from functools import lru_cache, wraps


def get_ttl_hash(seconds=3600):
    """Calculate hash value for TTL caching.

    Args:
        seconds (int, optional): Expiration time in seconds. Defaults to 3600.

    Returns:
        int: Hash value.
    """
    utime = datetime.datetime.now().timestamp()
    return round(utime / (seconds + 1))


def ttl_cache(ttl_seconds=3600):
    """A decorator for TTL cache functionality.

    Args:
        ttl_seconds (int, optional): Expiration time in seconds. Defaults to 3600.
    """
    def ttl_cache_deco(func):
        """Returns a function with time-to-live (TTL) caching capabilities."""
        # Function with caching capability and dummy argument
        @lru_cache(maxsize=None)
        def cached_dummy_func(*args, ttl_dummy, **kwargs):
            del ttl_dummy  # Remove the dummy argument
            return func(*args, **kwargs)

        # Function to input the hash value into the dummy argument
        @wraps(func)
        def ttl_cached_func(*args, **kwargs):
            hash = get_ttl_hash(ttl_seconds)
            return cached_dummy_func(*args, ttl_dummy=hash, **kwargs)
        return ttl_cached_func

    return ttl_cache_deco

To equip a function with TTL caching, simply prepend its definition with @ttl_cache(ttl_seconds=...).

Example
@ttl_cache(ttl_seconds=3600)
def get_content(url):
    ...

With our TTL cache decorator in place, there might be instances where you’d prefer fetching data directly rather than using the cache. To cater to this, it would be handy to have a “manual cache toggle” feature.

In the next article, we’ll discuss how to integrate this functionality.

Related Content