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 d66bbe4
Show file tree
Hide file tree
Showing 3 changed files with 456 additions and 0 deletions.
28 changes: 28 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,34 @@ 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()`. 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-live 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", "expire", "removed")

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

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


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.expire

def __getitem__(self, key, cache_getitem=Cache.__getitem__):
try:
item = self.__getitem(key)
except KeyError:
expired = False
else:
expired = not (self.__timer() < item.expire)
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:
expire = self.__ttu(key, value, time)
if not (time < expire):
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, expire)
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.expire):
raise KeyError(key)

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

def __len__(self):
time = self.__timer()
count = 0
for curr in self.__order:
if time < curr.expire 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].expire)):
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 d66bbe4

Please sign in to comment.