123

I have multiple threads running the same process that need to be able to to notify each other that something should not be worked on for the next n seconds its not the end of the world if they do however.

My aim is to be able to pass a string and a TTL to the cache and be able to fetch all the strings that are in the cache as a list. The cache can live in memory and the TTL's will be no more than 20 seconds.

Does anyone have a any suggestions for how this can be accomplished?

Asclepius
  • 57,944
  • 17
  • 167
  • 143
Louise McMahon
  • 1,655
  • 3
  • 15
  • 21
  • Regarding threading, in modern Python, [`threading.Barrier`](https://docs.python.org/3/library/threading.html#barrier-objects) should be of strong interest as it has a timeout. – Asclepius Jan 24 '19 at 23:58

14 Answers14

209

In case you don't want to use any 3rd libraries, you can add one more parameter to your expensive function: ttl_hash=None. This new parameter is so-called "time sensitive hash", its the only purpose is to affect lru_cache.

For example:

from functools import lru_cache
import time


@lru_cache()
def my_expensive_function(a, b, ttl_hash=None):
    del ttl_hash  # to emphasize we don't use it and to shut pylint up
    return a + b  # horrible CPU load...


def get_ttl_hash(seconds=3600):
    """Return the same value withing `seconds` time period"""
    return round(time.time() / seconds)


# somewhere in your code...
res = my_expensive_function(2, 2, ttl_hash=get_ttl_hash())
# cache will be updated once in an hour

iutinvg
  • 3,479
  • 3
  • 21
  • 18
  • 36
    This solution is really elegant. It's just a pity that this method doesn't guarantee that each cached value expires exactly after the TTL value. Indeed a cached value expires between 0 and TTL value. – luca Nov 25 '19 at 16:21
  • 1
    Looks nice, but can you please explain how/why it works? Is this a hack/workaround or an accepted idiom? – noamtm Dec 15 '19 at 14:07
  • 8
    @noamtm Thank you. It works because it exploits the fact that `lru_cache` depends on the set of given arguments, one of which (`ttl_hash`) stays the same within a time period. Then `ttl_hash` changes and, thus, creates a new set of arguments. `lru_cache` can't find a cache for this new set and calls cachable function. I wouldn't say it's a "hack", I think it's a "technic". Unfortunately, I don't possess enough authority to label it as "accepted idiom". I hope it just refers to "common sense". – iutinvg Dec 17 '19 at 13:50
  • This surly adds to my day to day technics, but I think it's imperative to mention that this should only be used for "Time Expensive" functions, not "Memory Expensive" ones! – aliqandil Dec 19 '20 at 01:46
  • 3
    It is also effectively a memory leak as old values will keep being cached but can never be reused as the hash keeps monotonically increasing ? – kardenal.mendoza Jan 27 '21 at 01:47
  • 12
    @kardenal.mendoza It should be ok as [`lru_cache`](https://docs.python.org/3/library/functools.html#functools.lru_cache) has `maxsize`. – iutinvg Jan 29 '21 at 14:00
  • 3
    small warning: stumbled on an unwanted side-effect of such implementation: such cache expires at the same time for all functions that use it and they all will recalculate new values at the same time – ShPavel Jan 12 '22 at 15:58
  • @luca It's easy to implement if you change get_ttl_hash function. You'll need non local (outer scope ones that persists like attribute of class or so) counter and last timestamp. You can increase counter and update the timestamp whenever it exceeds ttl, and return the counter value. – Jaehyun Yeom Apr 14 '22 at 08:32
148

The OP is using python 2.7 but if you're using python 3, ExpiringDict mentioned in the accepted answer is currently, well, expired. The last commit to the github repo was June 17, 2017 and there is an open issue that it doesn't work with Python 3.5

As of September 1, 2020, there is a more recently maintained project cachetools.

pip install cachetools

from cachetools import TTLCache

cache = TTLCache(maxsize=10, ttl=360)
cache['apple'] = 'top dog'
...
>>> cache['apple']
'top dog'
... after 360 seconds...
>>> cache['apple']
KeyError exception raised

ttl is the time to live in seconds.

firelynx
  • 30,616
  • 9
  • 91
  • 101
User
  • 62,498
  • 72
  • 186
  • 247
  • 10
    Be careful because the site has a note if you are not using the cachetools as a decorator you have to take care of locks since it is not thread-safe. Check out the note on the top of the site: https://cachetools.readthedocs.io/en/stable/ – Guigreg Sep 24 '20 at 23:03
  • 7
    @Guigreg it actually says you need to use a caching decorator AND a lock. "Access to a shared cache from multiple threads must be properly synchronized, e.g. by using one of the memoizing decorators with a suitable lock object." – Tommy Mar 01 '21 at 11:51
56

Regarding an expiring in-memory cache, for general purpose use, a common design pattern to typically do this is not via a dictionary, but via a function or method decorator. A cache dictionary is managed behind the scenes. As such, this answer somewhat complements the answer by User which uses a dictionary rather than a decorator.

The ttl_cache decorator in cachetools works a lot like functools.lru_cache, but with a time to live.

import cachetools.func

@cachetools.func.ttl_cache(maxsize=128, ttl=10 * 60)
def example_function(key):
    return get_expensively_computed_value(key)


class ExampleClass:
    EXP = 2

    @classmethod
    @cachetools.func.ttl_cache()
    def example_classmethod(cls, i):
        return i * cls.EXP

    @staticmethod
    @cachetools.func.ttl_cache()
    def example_staticmethod(i):
        return i * 3
Asclepius
  • 57,944
  • 17
  • 167
  • 143
  • 4
    Also, if you want a LRU cache with TTL `cachetools` has [that for you](https://cachetools.readthedocs.io/en/latest/#cachetools.TLRUCache). – bfontaine Feb 06 '23 at 15:05
33

You can use the expiringdict module:

The core of the library is ExpiringDict class which is an ordered dictionary with auto-expiring values for caching purposes.

In the description they do not talk about multithreading, so in order not to mess up, use a Lock.

Asclepius
  • 57,944
  • 17
  • 167
  • 143
enrico.bacis
  • 30,497
  • 10
  • 86
  • 115
  • 5
    In fact they use lock on set/get operations inside. – Dawid Gosławski Aug 02 '15 at 12:58
  • 1
    @alkuzad: Ok, then you can avoid the Lock entirely. – enrico.bacis Aug 02 '15 at 13:04
  • i gave this module a try but its would require some work for my use case as i only need a list and it seems you cant just loop over a ExpiringDict class as it give duplicates – Louise McMahon Aug 02 '15 at 13:30
  • 1
    @HeDares: I know that you need a list, but if your list does not contain duplicates, you can use the dictionary keys to represent the list values (and set the dictionary values to something like `None`). Then loop over them with `d.keys()` – enrico.bacis Aug 02 '15 at 13:37
  • 7
    As mentioned in https://stackoverflow.com/a/52128389/1260237 expiringdict appears to no longer be maintained and cachetools appears to be a good successor. – Jeff Klukas Sep 27 '18 at 16:48
28

I know this is a little old, but for those who are interested in no third-party dependencies, this is a minor wrapper around the builtin functools.lru_cache (I noticed Javier's similar answer after writing this, but figured I post it anyway since this doesn't require Django):

import functools
import time


def time_cache(max_age, maxsize=128, typed=False):
    """Least-recently-used cache decorator with time-based cache invalidation.

    Args:
        max_age: Time to live for cached results (in seconds).
        maxsize: Maximum cache size (see `functools.lru_cache`).
        typed: Cache on distinct input types (see `functools.lru_cache`).
    """
    def _decorator(fn):
        @functools.lru_cache(maxsize=maxsize, typed=typed)
        def _new(*args, __time_salt, **kwargs):
            return fn(*args, **kwargs)

        @functools.wraps(fn)
        def _wrapped(*args, **kwargs):
            return _new(*args, **kwargs, __time_salt=int(time.time() / max_age))

        return _wrapped

    return _decorator

And its usage:

@time_cache(10)
def expensive(a: int):
    """An expensive function."""
    time.sleep(1 + a)


print("Starting...")
expensive(1)
print("Again...")
expensive(1)
print("Done")

NB this uses time.time and comes with all its caveats. You may want to use time.monotonic instead if available/appropriate.

Lewis Belcher
  • 539
  • 5
  • 4
  • What are the caveats of using `time.time`? – AngryUbuntuNerd Aug 19 '21 at 12:36
  • Hi. Can you please explain what is `__time_salt`? I can't find anything about it on the internet. – Pawan Bhandarkar Aug 23 '21 at 06:51
  • 2
    @PawanBhandarkar lrc_cache taking all params of the function and creates a key, so if you change even one single key it will map to new entry thus refreshed. This is just a trick to force lrc_cache lib to provide TTL on top of max size. __time_salt can be named anything you like (hash, timed hash, timed salt does not matter) – Vladi Sep 26 '21 at 13:03
  • @AngryUbuntuNerd `time.time` relies on the system clock and may not be monotonic (i.e. it's not guaranteed to to always increase, it may in fact decrease if your machine syncs its system clock over a network for example). For cases where you need to rely on increasing time, `time.monotonic` should be preferred. – Lewis Belcher Nov 13 '21 at 21:29
  • @LewisBelcher i like your approach, but `max_age` is really the max and is arbitrary between 0 and `max_age`. Since you simply dividing and "round" the result. E.g. `time.time()/1000` could lead to `x.9999...` and doing this calculation 1 sec later leads to `(x+1).0000` and therefore the cache will not be used. – Chris Oct 04 '22 at 13:09
  • @chris You could adapt it to use an offset from the first call, using an internal cache if necessary – ijustlovemath Jul 03 '23 at 22:03
24

I absolutely love the idea from @iutinvg, I just wanted to take it a little further; decouple it from having to know to pass the ttl into every function and just make it a decorator so you don't have to think about it. If you have django, py3, and don't feel like pip installing any dependencies, try this out.

import time
from django.utils.functional import lazy
from functools import lru_cache, partial, update_wrapper


def lru_cache_time(seconds, maxsize=None):
    """
    Adds time aware caching to lru_cache
    """
    def wrapper(func):
        # Lazy function that makes sure the lru_cache() invalidate after X secs
        ttl_hash = lazy(lambda: round(time.time() / seconds), int)()
        
        @lru_cache(maxsize)
        def time_aware(__ttl, *args, **kwargs):
            """
            Main wrapper, note that the first argument ttl is not passed down. 
            This is because no function should bother to know this that 
            this is here.
            """
            def wrapping(*args, **kwargs):
                return func(*args, **kwargs)
            return wrapping(*args, **kwargs)
        return update_wrapper(partial(time_aware, ttl_hash), func)
    return wrapper

Please keep in mind that maxsize is None, but you REALLY should add a value here that is not None. This WILL cause a memory leak (the python instance will forever consume memory without anyway of getting rid of it until the program dies.)

Proving it works (with examples):

@lru_cache_time(seconds=10, maxsize=128)
def meaning_of_life():
    """
    This message should show up if you call help().
    """
    print('this better only show up once!')
    return 42


@lru_cache_time(seconds=10, maxsize=128)
def multiply(a, b):
    """
    This message should show up if you call help().
    """
    print('this better only show up once!')
    return a * b
    
# This is a test, prints a `.` for every second, there should be 10s 
# between each "this better only show up once!" *2 because of the two functions.
for _ in range(20):
    meaning_of_life()
    multiply(50, 99991)
    print('.')
    time.sleep(1)
Javier Buzzi
  • 6,296
  • 36
  • 50
13

Yet Another Solution

How it works?

  1. The user function is cached using @functools.lru_cache with support for maxsize and typed parameters.
  2. The Result object records the function's return value and "death" time using time.monotonic() + ttl.
  3. The wrapper function checks the "death" time of the return value against time.monotonic() and if the current time exceeds the "death" time, then recalculates the return value with a new "death" time.

Show me the code:

from functools import lru_cache, wraps
from time import monotonic


def lru_cache_with_ttl(maxsize=128, typed=False, ttl=60):
    """Least-recently used cache with time-to-live (ttl) limit."""

    class Result:
        __slots__ = ('value', 'death')

        def __init__(self, value, death):
            self.value = value
            self.death = death

    def decorator(func):
        @lru_cache(maxsize=maxsize, typed=typed)
        def cached_func(*args, **kwargs):
            value = func(*args, **kwargs)
            death = monotonic() + ttl
            return Result(value, death)

        @wraps(func)
        def wrapper(*args, **kwargs):
            result = cached_func(*args, **kwargs)
            if result.death < monotonic():
                result.value = func(*args, **kwargs)
                result.death = monotonic() + ttl
            return result.value

        wrapper.cache_clear = cached_func.cache_clear
        return wrapper

    return decorator

How to use it?

# Recalculate cached results after 5 seconds.
@lru_cache_with_ttl(ttl=5)
def expensive_function(a, b):
    return a + b

Benefits

  1. Short, easy to review, and no PyPI install necessary. Relies only on the Python standard library, 3.7+.
  2. No annoying ttl=10 parameter needed at all callsites.
  3. Does not evict all items at the same time.
  4. Key/value pairs actually live for the given TTL value.
  5. Stores only one key/value pair per unique (*args, **kwargs) even when items expire.
  6. Works as a decorator (kudos to the Javier Buzzi answer and Lewis Belcher answer).
  7. Is thread safe.
  8. Benefits from the C-optimizations of CPython from python.org and is compatible with PyPy.

The accepted answer fails #2, #3, #4, #5, and #6.

Drawbacks

Does not proactively evict expired items. Expired items are evicted only when the cache reaches maximum size. If the cache will not reach the maximum size (say maxsize is None), then no evictions will ever occur.

However, only one key/value pair is stored in the cache per unique (*args, **kwargs) given to the cached function. So if there are only 10 different parameter combinations, then the cache will only ever have 10 entries at max.

Note that the "time sensitive hash" and "time salt" solutions are much worse because multiple key/value cache items with identical keys (but different time hashes/salts) are left in the cache.

GrantJ
  • 8,162
  • 3
  • 52
  • 46
  • `result.value = func(*args, **kwargs)` <--- is this snippet actually modifying the `lru_cache `-ed Result object in-place? – soundstripe Mar 29 '22 at 13:30
  • 1
    Yes, sort of. The lru cache maintains the same reference to the Result object (that cannot be changed). But the value and “death” time within the Result object are updated (mutable). – GrantJ Mar 30 '22 at 16:13
  • @GrantJ - Not sure, but how will thread-safety work if a key has expired and 2 threads call the func with same args? See - `if result.death < monotonic():`, both thread will enter at the same time and will cause twice execution of the same func. – saurabheights Mar 03 '23 at 15:22
  • 1
    @saurabheights that’s correct. In your scenario, both threads will enter at the same time and the function will execute twice in parallel. There’s no barrier to prevent that. But it is still thread safe as the cache will not be corrupted. If you want the function to execute only once then you need a barrier like https://grantjenks.com/docs/diskcache/api.html#diskcache.barrier – GrantJ Mar 04 '23 at 16:59
8

If you want to avoid third-party packages, you can add in a custom timed_lru_cache decorator, which builds upon the lru_cache decorator.

The below defaults to a 20-second lifetime and a max size of 128. Note that the entire cache expires after 20 seconds, not individual items.

from datetime import datetime, timedelta
from functools import lru_cache, wraps


def timed_lru_cache(seconds: int = 20, maxsize: int = 128):
    def wrapper_cache(func):
        func = lru_cache(maxsize=maxsize)(func)
        func.lifetime = timedelta(seconds=seconds)
        func.expiration = datetime.utcnow() + func.lifetime

        @wraps(func)
        def wrapped_func(*args, **kwargs):
            if datetime.utcnow() >= func.expiration:
                func.cache_clear()
                func.expiration = datetime.utcnow() + func.lifetime

            return func(*args, **kwargs)

        return wrapped_func

    return wrapper_cache

Then, just add @timed_lru_cache() above your function and you'll be good to go:

@timed_lru_cache()
def my_function():
  # code goes here...
CodeBiker
  • 2,985
  • 2
  • 33
  • 36
  • 1
    You may need to call the wrapper even thought no arguments passed: `@timed_lru_cache()`. Otherwise, you may receive a `TypeError: Expected first argument to be an integer, a callable, or None`. – oscarah Dec 16 '21 at 12:14
5

Something like that ?

from time import time, sleep
import itertools
from threading import Thread, RLock
import signal


class CacheEntry():
  def __init__(self, string, ttl=20):
    self.string = string
    self.expires_at = time() + ttl
    self._expired = False

  def expired(self):
    if self._expired is False:
      return (self.expires_at < time())
    else:
      return self._expired

class CacheList():
  def __init__(self):
    self.entries = []
    self.lock = RLock()

  def add_entry(self, string, ttl=20):
    with self.lock:
        self.entries.append(CacheEntry(string, ttl))

  def read_entries(self):
    with self.lock:
        self.entries = list(itertools.dropwhile(lambda x:x.expired(), self.entries))
        return self.entries

def read_entries(name, slp, cachelist):
  while True:
    print "{}: {}".format(name, ",".join(map(lambda x:x.string, cachelist.read_entries())))
    sleep(slp)

def add_entries(name, ttl, cachelist):
  s = 'A'
  while True:
    cachelist.add_entry(s, ttl)
    print("Added ({}): {}".format(name, s))
    sleep(1)
    s += 'A'



if __name__ == "__main__":
  signal.signal(signal.SIGINT, signal.SIG_DFL)

  cl = CacheList()
  print_threads = []
  print_threads.append(Thread(None, read_entries, args=('t1', 1, cl)))
  # print_threads.append(Thread(None, read_entries, args=('t2', 2, cl)))
  # print_threads.append(Thread(None, read_entries, args=('t3', 3, cl)))

  adder_thread = Thread(None, add_entries, args=('a1', 2, cl))
  adder_thread.start()

  for t in print_threads:
    t.start()

  for t in print_threads:
    t.join()

  adder_thread.join()
Dawid Gosławski
  • 2,028
  • 1
  • 18
  • 25
4

I really liked @iutinvg solution due to its simplicity. However, I don't want to put an extra argument into every function, that I need to cache. So inspired by Lewis and Javiers answer, I thought a decorator would be best. However, I did not want to use 3rd party libraries (as Javier) and I thought I could improve upon Lewis solution. So this is what I came up with.

import time
from functools import lru_cache


def ttl_lru_cache(seconds_to_live: int, maxsize: int = 128):
    """
    Time aware lru caching
    """
    def wrapper(func):

        @lru_cache(maxsize)
        def inner(__ttl, *args, **kwargs):
            # Note that __ttl is not passed down to func,
            # as it's only used to trigger cache miss after some time
            return func(*args, **kwargs)
        return lambda *args, **kwargs: inner(time.time() // seconds_to_live, *args, **kwargs)
    return wrapper

My solution use a lambda to get fewer lines of code and integer floor division (//) so no casting to int is required.

Usage

@ttl_lru_cache(seconds_to_live=10)
def expensive(a: int):
    """An expensive function."""
    time.sleep(1 + a)


print("Starting...")
expensive(1)
print("Again...")
expensive(1)
print("Done")

Note: With these decorators, you should never set maxsize=None, because the cache would then grow to infinity over time.

McToel
  • 131
  • 1
  • 6
0

Here's my implementation

from functools import wraps
from time import time

def ttl_cache(ttl: int):
    def wrapper(func):
        cached = None
        cached_at = time()

        @wraps(func)
        def _decorator(*args, **kwargs):
            nonlocal cached, cached_at
            now = time()

            if (now - cached_at) >= ttl or cached is None:
                cached_at = time()
                cached = func(*args, **kwargs)

            return cached

        return _decorator

    return wrapper

Usage

@ttl_cache(5)
def work():
    return randint(0, 10)

while True:
    print(work())
    sleep(1)
edunn
  • 21
  • 1
  • 5
0

I absolutely love the idea from @iutinvg, while in some cases: expensive function return dynamic ttl, such as dig a domain, or get auth key, user should consider deadline. here's my rough codes:

@lru_cache(1000)
def expensive_fn(key, deadline=0):
    now = int(time.time())
    print(f'expensive func called with key: {key}')
    ttl, v = 60, f"v_{key}"
    if key > 'c':       # conditional demo
        ttl = -10
    return now + ttl, v


def test():
    deadlines = {}
    for _ in range(3):
        for key in "abcd":
            deadline, v = expensive_fn(key, deadlines.get(key, 0))
            now = int(time.time())
            if deadline < now:
                print(f'renew {key}')
                deadline, v = expensive_fn(key, now)
            deadlines[key] = deadline
whi
  • 2,685
  • 6
  • 33
  • 40
-1

You can also go for dictttl, which has MutableMapping, OrderedDict and defaultDict(list)

Initialize an ordinary dict with each key having a ttl of 30 seconds

data = {'a': 1, 'b': 2}
dict_ttl = DictTTL(30, data)

OrderedDict

data = {'a': 1, 'b': 2}
dict_ttl = OrderedDictTTL(30, data)

defaultDict(list)

dict_ttl = DefaultDictTTL(30)
data = {'a': [10, 20], 'b': [1, 2]}
[dict_ttl.append_values(k, v) for k, v in data.items()]
sid0972
  • 141
  • 1
  • 4
  • 13
-1

Somebody took some work to put it into a python package, see https://github.com/vpaliy/lru-expiring-cache.

Well, I've been mislead by the other answers (which don't really address the question), so this might not be the best tool. Still,

from lru import LruCache

cache = LruCache(maxsize=10, concurrent=True)

def producer(key: str, value = True, TTL = 20):
    cache.add(key = key, value = value, expires = TTL)

def consumer():
    remaining_items = cache.items()
    # Alternatively, iterate over available items until you find one not in the cache
    return remaining_items


producer("1", TTL = 1)
producer("5", TTL = 3)
print(consumer())  ## 1, 5

time.sleep(2)
print(consumer())  ## 5

time.sleep(2)
print(consumer())  ## nothing

To my surprise, it keeps a ('Concurrent', 'True') entry when running in concurrent mode.

olepinto
  • 351
  • 3
  • 8