2
\$\begingroup\$

I needed a simple expiring in-memory cache module for a project I'm working on and I've come up with the following.

My requirements for the cache module are:

  1. Be able to expire objects after certain period of time
  2. Use what we have in the standard library
  3. Keep it simple

So far I've got this.

"""
Expiring in-memory cache module

"""

import logging
import threading

from time import time
from collections import OrderedDict

__all__ = ['CachedObject', 'CacheInventory', 'CacheException']


class CacheException(Exception):
      """
      Generic cache exception
      """
      pass


class CachedObject(object):
    def __init__(self, name, obj, ttl):
        """
        Initializes a new cached object

        Args:
            name               (str): Human readable name for the cached entry
            obj               (type): Object to be cached
            ttl                (int): The TTL in seconds for the cached object

        """
        self.hits = 0
        self.name = name
        self.obj = obj
        self.ttl = ttl
        self.timestamp = time()


class CacheInventory(object):
    """
    Inventory for cached objects

    """
    def __init__(self, maxsize=0, housekeeping=0):
        """
        Initializes a new cache inventory

        Args:
            maxsize      (int): Upperbound limit on the number of items
                                that will be stored in the cache inventory
            housekeeping (int): Time in minutes to perform periodic
                                cache housekeeping

        """
        if maxsize < 0:
            raise CacheException('Cache inventory size cannot be negative')

        if housekeeping < 0:
            raise CacheException('Cache housekeeping period cannot be negative')

        self._cache = OrderedDict()
        self.maxsize = maxsize
        self.housekeeping = housekeeping * 60.0
        self.lock = threading.RLock()

        if self.housekeeping > 0:
            threading.Timer(self.housekeeping, self.housekeeper).start()

    def __len__(self):
        with self.lock:
            return len(self._cache)

    def __contains__(self, key):
        with self.lock:
            if key not in self._cache:
                return False

            item = self._cache[key]
            if self._has_expired(item):
                return False
            return True

    def _has_expired(self, item):
        """
        Checks if a cached item has expired and removes it if needed

        If the upperbound limit has been reached then the last item
        is being removed from the inventory.

        Args:
            item (CachedObject): A cached object to lookup

        """
        with self.lock:
            if time() > item.timestamp + item.ttl:
                logging.debug(
                    'Object %s has expired and will be removed from cache [hits %d]',
                    item.name,
                    item.hits
                )
                self._cache.pop(item.name)
                return True
            return False

    def add(self, obj):
        """
        Add an item to the cache inventory

        Args:
            obj (CachedObject): A CachedObject instance to be added

        Raises:
            CacheException

        """
        if not isinstance(obj, CachedObject):
            raise Exception('Need a CachedObject instance to add in the cache')

        with self.lock:
            if self.maxsize > 0 and len(self._cache) == self.maxsize:
                popped = self._cache.popitem(last=False)
                logging.debug('Cache maxsize reached, removing %s [hits %d]', popped.name, popped.hits)

            logging.debug('Caching object %s [ttl: %d seconds]', obj.name, obj.ttl)
            self._cache[obj.name] = obj

    def get(self, key):
        """
        Retrieve an object from the cache inventory

        Args:
            key (str): Name of the cache item to retrieve

        Returns:
            The cached object if found, None otherwise

        """
        with self.lock:
            if key not in self._cache:
                return None

            item = self._cache[key]
            if self._has_expired(item):
                return None

            item.hits += 1
            logging.debug(
                'Returning object %s from cache [hits %d]',
                item.name,
                item.hits
            )

            return item.obj

    def housekeeper(self):
        """
        Remove expired entries from the cache on regular basis

        """
        with self.lock:
            expired = 0
            logging.info(
                'Starting cache housekeeper [%d items in cache]',
                len(self._cache)
            )
            for name, item in self._cache.items():
                if self._has_expired(item):
                    expired += 1
            logging.info(
                'Cache housekeeper completed [%d removed from cache]',
                expired
            )
            if self.housekeeping > 0:
                threading.Timer(self.housekeeping, self.housekeeper).start()

Here's an example usage of the caching module:

>>> from __future__ import print_function
>>> from __future__ import absolute_import
>>> from . import CachedObject
>>> from . import CacheInventory
>>> cache = CacheInventory(housekeeping=60) # housekeeper will run every 60 minutes
>>> obj = {'key1': 'value1', 'key2': 'value2'}
>>> cached_obj = CachedObject(name='mydictionary', obj=obj, ttl=60) # object will expire in 60 seconds
>>> cache.add(obj=cached_obj)
>>> print(cache.get('mydictionary'))
{'key2': 'value2', 'key1': 'value1'}
>>> # 60 seconds later -> the object has expired already
... 
>>> print(cache.get('mydictionary'))
None

I'm currently using this caching module for storing VMware vSphere managed objects and the code can also be found in Github:

Any thoughts, remarks or suggestions about the design and implementation of this caching module?

Note: Code works in both Python 2 and Python 3.

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

In this loop you're not using name:

for name, item in self._cache.items():
    if self._has_expired(item):
        expired += 1

If you only need the values, then iterate over just the values:

for item in self._cache.values():
    if self._has_expired(item):
        expired += 1

You don't need an if statement here:

if self._has_expired(item):
    return False
return True

You can simplify by using the negated boolean expression directly:

return not self._has_expired(item)

This expression can be simplified:

if self.maxsize > 0 and len(self._cache) == self.maxsize:

Using chained comparison:

if 0 < self.maxsize == len(self._cache):
\$\endgroup\$
1
  • \$\begingroup\$ Nice catches, will make sure to update the module to reflect these recommendations. \$\endgroup\$ Jan 31, 2015 at 6:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.