Skip to content

Commit

Permalink
Fix #157: Add TLRU cache implementation.
Browse files Browse the repository at this point in the history
  • Loading branch information
tkem committed Dec 19, 2021
1 parent f949504 commit 9b731f5
Show file tree
Hide file tree
Showing 3 changed files with 469 additions and 1 deletion.
42 changes: 41 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ method calls.
.. testsetup:: *

import operator
from cachetools import cached, cachedmethod, LRUCache, TTLCache
from cachetools import cached, cachedmethod, LRUCache, TLRUCache, TTLCache

from unittest import mock
urllib = mock.MagicMock()
Expand Down Expand Up @@ -140,6 +140,46 @@ computed when the item is inserted into the cache.
items that have expired by the current value returned by
:attr:`timer`.

.. autoclass:: TLRUCache(maxsize, ttu, timer=time.monotonic, getsizeof=None)
:members: popitem, timer, ttu

Similar to :class:`TTLCache`, this class also associates an
expiration time with each item. However, for :class:`TLRUCache`
items, expiration time is calculated by a user-provided time-to-use
(`ttu`) function, which is passed three arguments at the time of
insertion: the new item's key and value, as well as the current
value of `timer()`.

.. testcode::

from datetime import datetime, timedelta

def my_ttu(_key, value, now):
# assume value.ttl contains the item's time-to-live in hours
return now + timedelta(hours=value.ttl)

cache = TLRUCache(maxsize=10, ttu=my_ttu, timer=datetime.now)

The expression `ttu(key, value, timer())` defines the expiration
time of a cache item, and must be comparable against later results
of `timer()`.

Items that expire because they have exceeded their time-to-use will
be no longer accessible, and will be removed eventually. If no
expired items are there to remove, the least recently used items
will be discarded first to make space when necessary.

.. method:: expire(self, time=None)

Expired items will be removed from a cache only at the next
mutating operation, e.g. :meth:`__setitem__` or
:meth:`__delitem__`, and therefore may still claim memory.
Calling this method removes all items whose time-to-use would
have expired by `time`, so garbage collection is free to reuse
their memory. If `time` is :const:`None`, this removes all
items that have expired by the current value returned by
:attr:`timer`.


Extending cache classes
-----------------------
Expand Down
161 changes: 161 additions & 0 deletions src/cachetools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"LRUCache",
"MRUCache",
"RRCache",
"TLRUCache",
"TTLCache",
"cached",
"cachedmethod",
Expand All @@ -17,6 +18,7 @@
import collections
import collections.abc
import functools
import heapq
import random
import time

Expand Down Expand Up @@ -497,6 +499,165 @@ def __getlink(self, key):
return value


@functools.total_ordering
class _TLRUItem:

__slots__ = ("key", "expires", "removed")

def __init__(self, key=None, expires=None):
self.key = key
self.expires = expires
self.removed = False

def __lt__(self, other):
return self.expires < other.expires


class TLRUCache(Cache):
"""Time aware Least Recently Used (TLRU) cache implementation."""

def __init__(self, maxsize, ttu, timer=time.monotonic, getsizeof=None):
Cache.__init__(self, maxsize, getsizeof)
self.__items = collections.OrderedDict()
self.__order = []
self.__timer = _Timer(timer)
self.__ttu = ttu

def __contains__(self, key):
try:
item = self.__items[key] # no reordering
except KeyError:
return False
else:
return self.__timer() < item.expires

def __getitem__(self, key, cache_getitem=Cache.__getitem__):
try:
item = self.__getitem(key)
except KeyError:
expired = False
else:
expired = not (self.__timer() < item.expires)
if expired:
return self.__missing__(key)
else:
return cache_getitem(self, key)

def __setitem__(self, key, value, cache_setitem=Cache.__setitem__):
with self.__timer as time:
expires = self.__ttu(key, value, time)
if not (time < expires):
return # skip expired items
self.expire(time)
cache_setitem(self, key, value)
# removing an existing item would break the heap structure, so
# only mark it as removed for now
try:
self.__getitem(key).removed = True
except KeyError:
pass
self.__items[key] = item = _TLRUItem(key, expires)
heapq.heappush(self.__order, item)

def __delitem__(self, key, cache_delitem=Cache.__delitem__):
with self.__timer as time:
# no self.expire() for performance reasons, e.g. self.clear() [#67]
cache_delitem(self, key)
item = self.__items.pop(key)
item.removed = True
if not (time < item.expires):
raise KeyError(key)

def __iter__(self):
for curr in self.__order:
# "freeze" time for iterator access
with self.__timer as time:
if time < curr.expires and not curr.removed:
yield curr.key

def __len__(self):
time = self.__timer()
count = 0
for curr in self.__order:
if time < curr.expires and not curr.removed:
count += 1
return count

def __repr__(self, cache_repr=Cache.__repr__):
with self.__timer as time:
self.expire(time)
return cache_repr(self)

@property
def currsize(self):
with self.__timer as time:
self.expire(time)
return super().currsize

@property
def timer(self):
"""The timer function used by the cache."""
return self.__timer

@property
def ttu(self):
"""The local time-to-use function used by the cache."""
return self.__ttu

def expire(self, time=None):
"""Remove expired items from the cache."""
if time is None:
time = self.__timer()
items = self.__items
order = self.__order
# clean up the heap if too many items are marked as removed
if len(order) > len(items) * 2:
self.__order = order = [item for item in order if not item.removed]
heapq.heapify(order)
cache_delitem = Cache.__delitem__
while order and (order[0].removed or not (time < order[0].expires)):
item = heapq.heappop(order)
if not item.removed:
cache_delitem(self, item.key)
del items[item.key]

def clear(self):
with self.__timer as time:
self.expire(time)
Cache.clear(self)

def get(self, *args, **kwargs):
with self.__timer:
return Cache.get(self, *args, **kwargs)

def pop(self, *args, **kwargs):
with self.__timer:
return Cache.pop(self, *args, **kwargs)

def setdefault(self, *args, **kwargs):
with self.__timer:
return Cache.setdefault(self, *args, **kwargs)

def popitem(self):
"""Remove and return the `(key, value)` pair least recently used that
has not already expired.
"""
with self.__timer as time:
self.expire(time)
try:
key = next(iter(self.__items))
except StopIteration:
raise KeyError("%s is empty" % self.__class__.__name__) from None
else:
return (key, self.pop(key))

def __getitem(self, key):
value = self.__items[key]
self.__items.move_to_end(key)
return value


def cached(cache, key=hashkey, lock=None):
"""Decorator to wrap a function with a memoizing callable that saves
results in a cache.
Expand Down
Loading

0 comments on commit 9b731f5

Please sign in to comment.