Flask-SQLAlchemy Caching

As my career evolves, I'm dealing more and more with restful APIs. While endpoint caching made a lot of sense when rendering rich Jinja templates output, (and still does for API) it is not as efficient when your client queries your objects database one by one and through listings.

Object layer caching

Endpoint caching would be caching the response of a controller. We do that a lot at SodaHead when it comes to logged out pages. The trick is to use a middleware to store dynamic data into the session to keep it dynamic.

Object layer caching, on the other hand, would be the caching of your ORM objects. SQLAlchemy recommend dogpile.cache for that.

There are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one error.

-- Some Genius + Phil Karlton

Caching is tricky and very simple at the same time. Caching one object is easy: serializing an object and storing it in memcached is pretty simple. Invalidation of this object and its dependancies is not that easy. The way I define caching is like a large safe road but allowing you to wander out of it. I'll provide simple safe solutions that will help you gain in performance while being low maintenance. For more complex cases you'll be able to still cache query results but will have to manually invalidate those caches.

Use cases

I see two major cases for caching:

  • pulling one object from the database through its primary key
  • pulling a list of (related) objects.

Single object

SQLAchemy provides query.get to simply retrieve an object using its primary key, so we will replicate that behavior.

# pulling one User object
user = User.query.get(1)  
# pulling one User object from cache
user = User.cache.get(1)  

For convenience, you can even provide a get_or_404 method: do it yourself, it's easy.

Listing of objects

For this specific case, we'll go with email addresses, a 1..n relationship (meaning one user can have multiple email addresses, but an address object can only have one user)

# user is the object we pulled earlier (either from cache or not)
email_addresses = EmailAddress.cache.filter(user_id=1)  

You can even define a property on the User object itself to handle it:

class User(db.Model...):  
    ...
    @property
    def cached_email_addresses(self):
        return EmailAddress.cache.filter(user_id=self.id)

And voilĂ , you can now call it from the object too (cached or not, btw)

email_addresses = user.cached_email_addresses  

Note: I cannot stress it enough, caching is tricky. The filter method is purposely simple and will only accept one column which should belong to the object.

For listings, I picked a way of caching that you might not be familiar with. Listing caches are just a list of primary keys and rely on single object cache pulling. The logic is approximately the following (but optimized to pull batches of objects from cache):

Retrieve list of IDs from cache
if no IDs cached:
    pull list of IDs from database
    store list of IDs in cache
for every ID in list of IDs:
    yield object with pk == ID retrieved from cache

Storing object IDs is incredibly much smaller. The benefit is that a single object invalidation will only impact a small part of the listing it is related to. Listing calls will warm up single object caches and this technique allows some basic query features like offset, limit and order_by (only ASC/DESC, not a real column order_by ) without the need to invalidate the cache. This will allow better performance as the less you request, the less will be retrieved and instanciated from your cache backend. Overall, that should cover at least 50% of the cases (absolutely no study to back-up this number though).

Flask Models

Flask-SQLAlchemy inverts the logic a little, associating the query on the model itself which can be unsettling at first. The Model class provided by the Flask-SQLAlchemy library allows you to pass your own custom query object as query_class property, we'll use our callable here:

from caching import CacheableMixin, query_callable, regions

class User(db.Model, CacheableMixin):  
   cache_label = "default" # region's label to use
   cache_regions = regions # regions to store cache
   # Query handeling dogpile caching
   query_class = query_callable(regions)

   id = db.Column(db.Integer, primary_key=True)
   username = db.Column(db.String(80), unique=True)
   email = db.Column(db.String(120), unique=True)
   views = db.Column(db.Integer, default=0)

   def __init__(self, username, email):
       self.username = username
       self.email = email

   def __repr__(self):
       return '<User %r>' % self.username

The Caching Library

Here is where the magic lives. Every model with caching capability will inherit the CacheableMixin class. The mixin will also add event listener to smart flush caches on update.

 # caching.py
 import functools
 import hashlib

 from flask.ext.sqlalchemy import BaseQuery
 from sqlalchemy import event, select
 from sqlalchemy.orm.interfaces import MapperOption
 from sqlalchemy.orm.attributes import get_history
 from sqlalchemy.ext.declarative import declared_attr
 from dogpile.cache.region import make_region
 from dogpile.cache.api import NO_VALUE


 def md5_key_mangler(key):
     """
     Encodes SELECT queries (key) into md5 hashes
     """
     if key.startswith('SELECT '):
         key = hashlib.md5(key.encode('ascii')).hexdigest()
     return key


 def memoize(obj):
     """
     Local cache of the function's return value
     """
     cache = obj.cache = {}

     @functools.wraps(obj)
     def memoizer(*args, **kwargs):
         key = str(args) + str(kwargs)
         if key not in cache:
             cache[key] = obj(*args, **kwargs)
         return cache[key]
     return memoizer

 # if your app runs on multiple machine behind a
 # load balancer, I'd recommend the memcache backend.
 cache_config = {
     'backend': 'dogpile.cache.memory',
     'expiration_time': 3600, # 1 hour
 }

 regions = dict(
     default=make_region(key_mangler=md5_key_mangler).configure(**cache_config)
 )


 class CachingQuery(BaseQuery):
     """
     A Query subclass which optionally loads full results from a dogpile
     cache region.
     """

     def __init__(self, regions, entities, *args, **kw):
         self.cache_regions = regions
         BaseQuery.__init__(self, entities=entities, *args, **kw)

     def __iter__(self):
         """
         override __iter__ to pull results from dogpile
         if particular attributes have been configured.
         """
         if hasattr(self, '_cache_region'):
             return self.get_value(createfunc=lambda: list(BaseQuery.__iter__(self)))
         else:
             return BaseQuery.__iter__(self)

     def _get_cache_plus_key(self):
         """
         Return a cache region plus key.
         """

         dogpile_region = self.cache_regions[self._cache_region.region]
         if self._cache_region.cache_key:
             key = self._cache_region.cache_key
         else:
             key = _key_from_query(self)
         return dogpile_region, key

     def invalidate(self):
         """
         Invalidate the cache value represented by this Query.
         """

         dogpile_region, cache_key = self._get_cache_plus_key()
         dogpile_region.delete(cache_key)

     def get_value(self, merge=True, createfunc=None,
                     expiration_time=None, ignore_expiration=False):
         """
         Return the value from the cache for this query.

         Raise KeyError if no value present and no
         createfunc specified.
         """
         dogpile_region, cache_key = self._get_cache_plus_key()

         assert not ignore_expiration or not createfunc, \
                 "Can't ignore expiration and also provide createfunc"

         if ignore_expiration or not createfunc:
             cached_value = dogpile_region.get(cache_key,
                                 expiration_time=expiration_time,
                                 ignore_expiration=ignore_expiration)
         else:
             cached_value = dogpile_region.get_or_create(
                                 cache_key,
                                 createfunc,
                                 expiration_time=expiration_time)

         if cached_value is NO_VALUE:
             raise KeyError(cache_key)
         if merge:
             cached_value = self.merge_result(cached_value, load=False)

         return cached_value


     def set_value(self, value):
         """
         Set the value in the cache for this query.
         """

         dogpile_region, cache_key = self._get_cache_plus_key()
         dogpile_region.set(cache_key, value)


 def query_callable(regions, query_cls=CachingQuery):
   return functools.partial(query_cls, regions)


 def _key_from_query(query, qualifier=None):
     """
     Given a Query, create a cache key.
     """

     stmt = query.with_labels().statement
     compiled = stmt.compile()
     params = compiled.params

     return " ".join(
                     [str(compiled)] +
                     [str(params[k]) for k in sorted(params)])


 class FromCache(MapperOption):
     """Specifies that a Query should load results from a cache."""

     propagate_to_loaders = False

     def __init__(self, region="default", cache_key=None):
         """Construct a new FromCache.

         :param region: the cache region.  Should be a
         region configured in the dictionary of dogpile
         regions.

         :param cache_key: optional.  A string cache key
         that will serve as the key to the query.   Use this
         if your query has a huge amount of parameters (such
         as when using in_()) which correspond more simply to
         some other identifier.

         """
         self.region = region
         self.cache_key = cache_key

     def process_query(self, query):
         """Process a Query during normal loading operation."""
         query._cache_region = self


 class Cache(object):
     def __init__(self, model, regions, label):
         self.model = model
         self.regions = regions
         self.label = label
         # allow custom pk or default to 'id'
         self.pk = getattr(model, 'cache_pk', 'id')


     def get(self, pk):
         """
         Equivalent to the Model.query.get(pk) but using cache
         """
         return self.model.query.options(self.from_cache(pk=pk)).get(pk)


     def filter(self, order_by='asc', offset=None, limit=None, **kwargs):
         """
         Retrieve all the objects ids then pull them independently from cache.
         kwargs accepts one attribute filter, mainly for relationship pulling.
         offset and limit allow pagination, order by for sorting (asc/desc).
         """
         query_kwargs = {}
         if kwargs:
             if len(kwargs) > 1:
                 raise TypeError('filter accept only one attribute for filtering')
             key, value = kwargs.items()[0]
             if key not in self._columns():
                 raise TypeError('%s does not have an attribute %s' % self, key)
             query_kwargs[key] = value

         cache_key = self._cache_key(**kwargs)
         pks = self.regions[self.label].get(cache_key)

         if pks is NO_VALUE:
             pks = [o.id for o in self.model.query.filter_by(**kwargs)\
                 .with_entities(getattr(self.model, self.pk))]
             self.regions[self.label].set(cache_key, pks)

         if order_by == 'desc':
             pks.reverse()

         if offset is not None:
             pks = pks[pks:]

         if limit is not None:
             pks = pks[:limit]

         keys = [self._cache_key(id) for id in pks]
         for pos, obj in enumerate(self.regions[self.label].get_multi(keys)):
             if obj is NO_VALUE:
                 yield self.get(pks[pos])
             else:
                 yield obj[0]


     def flush(self, key):
         """
         flush the given key from dogpile.cache
         """
         self.regions[self.label].delete(key)


     @memoize
     def _columns(self):
         return [c.name for c in self.model.__table__.columns if c.name != self.pk]


     @memoize
     def from_cache(self, cache_key=None, pk=None):
         """
         build the from cache option object the the given object
         """
         if pk:
             cache_key = self._cache_key(pk)
         # if cache_key is none, the mangler will generate a MD5 from the query
         return FromCache(self.label, cache_key)


     @memoize
     def _cache_key(self, pk="all", **kwargs):
         """
         Generate a key as query
         format: '<tablename>.<column>[<value>]'

         'user.id[all]': all users
         'address.user_id=4[all]': all address linked to user id 4
         'user.id[4]': user with id=4
         """
         q_filter = "".join("%s=%s" % (k, v) for k, v in kwargs.items()) or self.pk
         return "%s.%s[%s]" % (self.model.__tablename__, q_filter, pk)


     def _flush_all(self, obj):
         for column in self._columns():
             added, unchanged, deleted = get_history(obj, column)
             for value in list(deleted) + list(added):
                 self.flush(self._cache_key(**{column: value}))
         # flush "all" listing
         self.flush(self._cache_key())
         # flush the object
         self.flush(self._cache_key(getattr(obj, self.pk)))


 class CacheableMixin(object):

     @declared_attr
     def cache(cls):
         """
         Add the cache features to the model
         """
         return Cache(cls, cls.cache_regions, cls.cache_label)


     @staticmethod
     def _flush_event(mapper, connection, target):
         """
         Called on object modification to flush cache of dependencies
         """
         target.cache._flush_all(target)


     @classmethod
     def __declare_last__(cls):
         """
         Auto clean the caches, including listings possibly associated with
         this instance, on delete, update and insert.
         """
         event.listen(cls, 'before_delete', cls._flush_event)
         event.listen(cls, 'before_update', cls._flush_event)
         event.listen(cls, 'before_insert', cls._flush_event)

Caching Specific Filters

The default filtering is limited to one attribute, this is to guarantee cache flush on property update. Each time you update a property of an object, the corresponding listing cache will be flushed. If you need more specific filtering but still would like to cache the result, you can resort to basic dogpile caching:

users = User.query.\  
     options(User.cache.from_cache("user_examples")).\
     filter(User.email.endswith('@example.com')).\
     all()

Results would then be stored in dogpile cache backend as "user_examples". To flush that record:

User.cache.flush("user_examples")  

Performance

So, is it faster ? Overall the gain is at least 2x faster then SQLite, either retrieval of single objects or list of objects.

Single Object

SQLite

 Brices-MacBook-Air:app briceleroy$ ab -n 5000 -c 50 http://127.0.0.1:5000/1/
 This is ApacheBench, Version 2.3 <$Revision: 655654 $>
 Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
 Licensed to The Apache Software Foundation, http://www.apache.org/

 Benchmarking 127.0.0.1 (be patient)
 Completed 500 requests
 Completed 1000 requests
 Completed 1500 requests
 Completed 2000 requests
 Completed 2500 requests
 Completed 3000 requests
 Completed 3500 requests
 Completed 4000 requests
 Completed 4500 requests
 Completed 5000 requests
 Finished 5000 requests


 Server Software:        Werkzeug/0.9.6
 Server Hostname:        127.0.0.1
 Server Port:            5000

 Document Path:          /1/
 Document Length:        89 bytes

 Concurrency Level:      50
 Time taken for tests:   15.413 seconds
 Complete requests:      5000
 Failed requests:        0
 Write errors:           0
 Total transferred:      1210000 bytes
 HTML transferred:       445000 bytes
 Requests per second:    324.40 [#/sec] (mean)
 Time per request:       154.129 [ms] (mean)
 Time per request:       3.083 [ms] (mean, across all concurrent requests)
 Transfer rate:          76.67 [Kbytes/sec] received

 Connection Times (ms)
               min  mean[+/-sd] median   max
 Connect:        0    0   0.2      0       2
 Processing:    14  153  10.2    151     196
 Waiting:       13  153  10.2    151     193
 Total:         16  153  10.2    151     196

 Percentage of the requests served within a certain time (ms)
   50%    151
   66%    155
   75%    158
   80%    160
   90%    163
   95%    168
   98%    175
   99%    181
  100%    196 (longest request)

Dogpile memory

 Brices-MacBook-Air:app briceleroy$ ab -n 5000 -c 50 http://127.0.0.1:5000/1/
 This is ApacheBench, Version 2.3 <$Revision: 655654 $>
 Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
 Licensed to The Apache Software Foundation, http://www.apache.org/

 Benchmarking 127.0.0.1 (be patient)
 Completed 500 requests
 Completed 1000 requests
 Completed 1500 requests
 Completed 2000 requests
 Completed 2500 requests
 Completed 3000 requests
 Completed 3500 requests
 Completed 4000 requests
 Completed 4500 requests
 Completed 5000 requests
 Finished 5000 requests


 Server Software:        Werkzeug/0.9.6
 Server Hostname:        127.0.0.1
 Server Port:            5000

 Document Path:          /1/
 Document Length:        89 bytes

 Concurrency Level:      50
 Time taken for tests:   7.988 seconds
 Complete requests:      5000
 Failed requests:        0
 Write errors:           0
 Total transferred:      1210000 bytes
 HTML transferred:       445000 bytes
 Requests per second:    625.93 [#/sec] (mean)
 Time per request:       79.881 [ms] (mean)
 Time per request:       1.598 [ms] (mean, across all concurrent requests)
 Transfer rate:          147.93 [Kbytes/sec] received

 Connection Times (ms)
               min  mean[+/-sd] median   max
 Connect:        0    0   0.1      0       1
 Processing:     4   79   8.2     76     111
 Waiting:        3   79   8.2     76     111
 Total:          5   79   8.2     76     111

 Percentage of the requests served within a certain time (ms)
   50%     76
   66%     79
   75%     82
   80%     84
   90%     90
   95%     97
   98%    103
   99%    106
  100%    111 (longest request)

Listing of Objects

SQLite

 Brices-MacBook-Air:app briceleroy$ ab -n 5000 -c 50 http://127.0.0.1:5000/
 This is ApacheBench, Version 2.3 <$Revision: 655654 $>
 Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
 Licensed to The Apache Software Foundation, http://www.apache.org/

 Benchmarking 127.0.0.1 (be patient)
 Completed 500 requests
 Completed 1000 requests
 Completed 1500 requests
 Completed 2000 requests
 Completed 2500 requests
 Completed 3000 requests
 Completed 3500 requests
 Completed 4000 requests
 Completed 4500 requests
 Completed 5000 requests
 Finished 5000 requests


 Server Software:        Werkzeug/0.9.6
 Server Hostname:        127.0.0.1
 Server Port:            5000

 Document Path:          /
 Document Length:        5247 bytes

 Concurrency Level:      50
 Time taken for tests:   24.206 seconds
 Complete requests:      5000
 Failed requests:        0
 Write errors:           0
 Total transferred:      27010000 bytes
 HTML transferred:       26235000 bytes
 Requests per second:    206.56 [#/sec] (mean)
 Time per request:       242.059 [ms] (mean)
 Time per request:       4.841 [ms] (mean, across all concurrent requests)
 Transfer rate:          1089.69 [Kbytes/sec] received

 Connection Times (ms)
               min  mean[+/-sd] median   max
 Connect:        0    0   0.6      0      10
 Processing:    17  240  27.1    237     519
 Waiting:       17  240  27.0    236     519
 Total:         27  241  26.8    237     520

 Percentage of the requests served within a certain time (ms)
   50%    237
   66%    244
   75%    247
   80%    250
   90%    257
   95%    267
   98%    310
   99%    342
  100%    520 (longest request)

dogpile memory

 Brices-MacBook-Air:app briceleroy$ ab -n 5000 -c 50 http://127.0.0.1:5000/
 This is ApacheBench, Version 2.3 <$Revision: 655654 $>
 Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
 Licensed to The Apache Software Foundation, http://www.apache.org/

 Benchmarking 127.0.0.1 (be patient)
 Completed 500 requests
 Completed 1000 requests
 Completed 1500 requests
 Completed 2000 requests
 Completed 2500 requests
 Completed 3000 requests
 Completed 3500 requests
 Completed 4000 requests
 Completed 4500 requests
 Completed 5000 requests
 Finished 5000 requests


 Server Software:        Werkzeug/0.9.6
 Server Hostname:        127.0.0.1
 Server Port:            5000

 Document Path:          /
 Document Length:        5247 bytes

 Concurrency Level:      50
 Time taken for tests:   9.678 seconds
 Complete requests:      5000
 Failed requests:        0
 Write errors:           0
 Total transferred:      27010000 bytes
 HTML transferred:       26235000 bytes
 Requests per second:    516.62 [#/sec] (mean)
 Time per request:       96.784 [ms] (mean)
 Time per request:       1.936 [ms] (mean, across all concurrent requests)
 Transfer rate:          2725.35 [Kbytes/sec] received

 Connection Times (ms)
               min  mean[+/-sd] median   max
 Connect:        0    0   0.1      0       1
 Processing:     3   96  14.3     91     167
 Waiting:        3   96  14.3     91     167
 Total:          4   96  14.3     91     168

 Percentage of the requests served within a certain time (ms)
   50%     91
   66%     93
   75%     97
   80%    102
   90%    113
   95%    125
   98%    138
   99%    159
  100%    168 (longest request)

Gist

If you want, there is a gist with the app and the library on github.

Comments on Hacker News or Reddit