Skip to content

Commit fc26da6

Browse files
committed
Add warning when using cache keys that might not work with memcached.
This means testing with local dev caches (not memcache) will warn developers if they are introducing inadvertent importabilities. There is also the ability to silence the warning if a dev is not planning to use memcache and knows what they are doing with their keys. Thanks to Carl Meyer for the patch. Fixed #6447. git-svn-id: http://code.djangoproject.com/svn/django/trunk@13766 bcc190cf-cafb-0310-a4f2-bffc1f526a37
1 parent bfbc259 commit fc26da6

File tree

10 files changed

+167
-10
lines changed

10 files changed

+167
-10
lines changed

django/core/cache/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from cgi import parse_qsl
1919
from django.conf import settings
2020
from django.core import signals
21-
from django.core.cache.backends.base import InvalidCacheBackendError
21+
from django.core.cache.backends.base import InvalidCacheBackendError, CacheKeyWarning
2222
from django.utils import importlib
2323

2424
# Name for use in settings file --> name of module in "backends" directory.

django/core/cache/backends/base.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
"Base Cache class."
22

3-
from django.core.exceptions import ImproperlyConfigured
3+
import warnings
4+
5+
from django.core.exceptions import ImproperlyConfigured, DjangoRuntimeWarning
46

57
class InvalidCacheBackendError(ImproperlyConfigured):
68
pass
79

10+
class CacheKeyWarning(DjangoRuntimeWarning):
11+
pass
12+
13+
# Memcached does not accept keys longer than this.
14+
MEMCACHE_MAX_KEY_LENGTH = 250
15+
816
class BaseCache(object):
917
def __init__(self, params):
1018
timeout = params.get('timeout', 300)
@@ -116,3 +124,21 @@ def delete_many(self, keys):
116124
def clear(self):
117125
"""Remove *all* values from the cache at once."""
118126
raise NotImplementedError
127+
128+
def validate_key(self, key):
129+
"""
130+
Warn about keys that would not be portable to the memcached
131+
backend. This encourages (but does not force) writing backend-portable
132+
cache code.
133+
134+
"""
135+
if len(key) > MEMCACHE_MAX_KEY_LENGTH:
136+
warnings.warn('Cache key will cause errors if used with memcached: '
137+
'%s (longer than %s)' % (key, MEMCACHE_MAX_KEY_LENGTH),
138+
CacheKeyWarning)
139+
for char in key:
140+
if ord(char) < 33 or ord(char) == 127:
141+
warnings.warn('Cache key contains characters that will cause '
142+
'errors if used with memcached: %r' % key,
143+
CacheKeyWarning)
144+

django/core/cache/backends/db.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class CacheEntry(object):
4646
self._cull_frequency = 3
4747

4848
def get(self, key, default=None):
49+
self.validate_key(key)
4950
db = router.db_for_read(self.cache_model_class)
5051
table = connections[db].ops.quote_name(self._table)
5152
cursor = connections[db].cursor()
@@ -65,9 +66,11 @@ def get(self, key, default=None):
6566
return pickle.loads(base64.decodestring(value))
6667

6768
def set(self, key, value, timeout=None):
69+
self.validate_key(key)
6870
self._base_set('set', key, value, timeout)
6971

7072
def add(self, key, value, timeout=None):
73+
self.validate_key(key)
7174
return self._base_set('add', key, value, timeout)
7275

7376
def _base_set(self, mode, key, value, timeout=None):
@@ -103,6 +106,7 @@ def _base_set(self, mode, key, value, timeout=None):
103106
return True
104107

105108
def delete(self, key):
109+
self.validate_key(key)
106110
db = router.db_for_write(self.cache_model_class)
107111
table = connections[db].ops.quote_name(self._table)
108112
cursor = connections[db].cursor()
@@ -111,6 +115,7 @@ def delete(self, key):
111115
transaction.commit_unless_managed(using=db)
112116

113117
def has_key(self, key):
118+
self.validate_key(key)
114119
db = router.db_for_read(self.cache_model_class)
115120
table = connections[db].ops.quote_name(self._table)
116121
cursor = connections[db].cursor()

django/core/cache/backends/dummy.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,25 @@ class CacheClass(BaseCache):
66
def __init__(self, *args, **kwargs):
77
pass
88

9-
def add(self, *args, **kwargs):
9+
def add(self, key, *args, **kwargs):
10+
self.validate_key(key)
1011
return True
1112

1213
def get(self, key, default=None):
14+
self.validate_key(key)
1315
return default
1416

15-
def set(self, *args, **kwargs):
16-
pass
17+
def set(self, key, *args, **kwargs):
18+
self.validate_key(key)
1719

18-
def delete(self, *args, **kwargs):
19-
pass
20+
def delete(self, key, *args, **kwargs):
21+
self.validate_key(key)
2022

2123
def get_many(self, *args, **kwargs):
2224
return {}
2325

24-
def has_key(self, *args, **kwargs):
26+
def has_key(self, key, *args, **kwargs):
27+
self.validate_key(key)
2528
return False
2629

2730
def set_many(self, *args, **kwargs):

django/core/cache/backends/filebased.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ def __init__(self, dir, params):
3232
self._createdir()
3333

3434
def add(self, key, value, timeout=None):
35+
self.validate_key(key)
3536
if self.has_key(key):
3637
return False
3738

3839
self.set(key, value, timeout)
3940
return True
4041

4142
def get(self, key, default=None):
43+
self.validate_key(key)
4244
fname = self._key_to_file(key)
4345
try:
4446
f = open(fname, 'rb')
@@ -56,6 +58,7 @@ def get(self, key, default=None):
5658
return default
5759

5860
def set(self, key, value, timeout=None):
61+
self.validate_key(key)
5962
fname = self._key_to_file(key)
6063
dirname = os.path.dirname(fname)
6164

@@ -79,6 +82,7 @@ def set(self, key, value, timeout=None):
7982
pass
8083

8184
def delete(self, key):
85+
self.validate_key(key)
8286
try:
8387
self._delete(self._key_to_file(key))
8488
except (IOError, OSError):
@@ -95,6 +99,7 @@ def _delete(self, fname):
9599
pass
96100

97101
def has_key(self, key):
102+
self.validate_key(key)
98103
fname = self._key_to_file(key)
99104
try:
100105
f = open(fname, 'rb')

django/core/cache/backends/locmem.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def __init__(self, _, params):
3030
self._lock = RWLock()
3131

3232
def add(self, key, value, timeout=None):
33+
self.validate_key(key)
3334
self._lock.writer_enters()
3435
try:
3536
exp = self._expire_info.get(key)
@@ -44,6 +45,7 @@ def add(self, key, value, timeout=None):
4445
self._lock.writer_leaves()
4546

4647
def get(self, key, default=None):
48+
self.validate_key(key)
4749
self._lock.reader_enters()
4850
try:
4951
exp = self._expire_info.get(key)
@@ -76,6 +78,7 @@ def _set(self, key, value, timeout=None):
7678
self._expire_info[key] = time.time() + timeout
7779

7880
def set(self, key, value, timeout=None):
81+
self.validate_key(key)
7982
self._lock.writer_enters()
8083
# Python 2.4 doesn't allow combined try-except-finally blocks.
8184
try:
@@ -87,6 +90,7 @@ def set(self, key, value, timeout=None):
8790
self._lock.writer_leaves()
8891

8992
def has_key(self, key):
93+
self.validate_key(key)
9094
self._lock.reader_enters()
9195
try:
9296
exp = self._expire_info.get(key)
@@ -127,6 +131,7 @@ def _delete(self, key):
127131
pass
128132

129133
def delete(self, key):
134+
self.validate_key(key)
130135
self._lock.writer_enters()
131136
try:
132137
self._delete(key)

django/core/exceptions.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
"Global Django exceptions"
1+
"""
2+
Global Django exception and warning classes.
3+
"""
4+
5+
class DjangoRuntimeWarning(RuntimeWarning):
6+
pass
27

38
class ObjectDoesNotExist(Exception):
49
"The requested object does not exist"

docs/topics/cache.txt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,45 @@ nonexistent cache key.::
641641
However, if the backend doesn't natively provide an increment/decrement
642642
operation, it will be implemented using a two-step retrieve/update.
643643

644+
Cache key warnings
645+
------------------
646+
647+
.. versionadded:: 1.3
648+
649+
Memcached, the most commonly-used production cache backend, does not allow
650+
cache keys longer than 250 characters or containing whitespace or control
651+
characters, and using such keys will cause an exception. To encourage
652+
cache-portable code and minimize unpleasant surprises, the other built-in cache
653+
backends issue a warning (``django.core.cache.backends.base.CacheKeyWarning``)
654+
if a key is used that would cause an error on memcached.
655+
656+
If you are using a production backend that can accept a wider range of keys (a
657+
custom backend, or one of the non-memcached built-in backends), and want to use
658+
this wider range without warnings, you can silence ``CacheKeyWarning`` with
659+
this code in the ``management`` module of one of your
660+
:setting:`INSTALLED_APPS`::
661+
662+
import warnings
663+
664+
from django.core.cache import CacheKeyWarning
665+
666+
warnings.simplefilter("ignore", CacheKeyWarning)
667+
668+
If you want to instead provide custom key validation logic for one of the
669+
built-in backends, you can subclass it, override just the ``validate_key``
670+
method, and follow the instructions for `using a custom cache backend`_. For
671+
instance, to do this for the ``locmem`` backend, put this code in a module::
672+
673+
from django.core.cache.backends.locmem import CacheClass as LocMemCacheClass
674+
675+
class CacheClass(LocMemCacheClass):
676+
def validate_key(self, key):
677+
"""Custom validation, raising exceptions or warnings as needed."""
678+
# ...
679+
680+
...and use the dotted Python path to this module as the scheme portion of your
681+
:setting:`CACHE_BACKEND`.
682+
644683
Upstream caches
645684
===============
646685

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.core.cache.backends.locmem import CacheClass as LocMemCacheClass
2+
3+
class LiberalKeyValidationMixin(object):
4+
def validate_key(self, key):
5+
pass
6+
7+
class CacheClass(LiberalKeyValidationMixin, LocMemCacheClass):
8+
pass
9+

tests/regressiontests/cache/tests.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
import tempfile
99
import time
1010
import unittest
11+
import warnings
1112

1213
from django.conf import settings
1314
from django.core import management
1415
from django.core.cache import get_cache
15-
from django.core.cache.backends.base import InvalidCacheBackendError
16+
from django.core.cache.backends.base import InvalidCacheBackendError, CacheKeyWarning
1617
from django.http import HttpResponse, HttpRequest
1718
from django.middleware.cache import FetchFromCacheMiddleware, UpdateCacheMiddleware
1819
from django.utils import translation
@@ -366,6 +367,33 @@ def perform_cull_test(self, initial_count, final_count):
366367
count = count + 1
367368
self.assertEqual(count, final_count)
368369

370+
def test_invalid_keys(self):
371+
"""
372+
All the builtin backends (except memcached, see below) should warn on
373+
keys that would be refused by memcached. This encourages portable
374+
caching code without making it too difficult to use production backends
375+
with more liberal key rules. Refs #6447.
376+
377+
"""
378+
# On Python 2.6+ we could use the catch_warnings context
379+
# manager to test this warning nicely. Since we can't do that
380+
# yet, the cleanest option is to temporarily ask for
381+
# CacheKeyWarning to be raised as an exception.
382+
warnings.simplefilter("error", CacheKeyWarning)
383+
384+
# memcached does not allow whitespace or control characters in keys
385+
self.assertRaises(CacheKeyWarning, self.cache.set, 'key with spaces', 'value')
386+
# memcached limits key length to 250
387+
self.assertRaises(CacheKeyWarning, self.cache.set, 'a' * 251, 'value')
388+
389+
# The warnings module has no public API for getting the
390+
# current list of warning filters, so we can't save that off
391+
# and reset to the previous value, we have to globally reset
392+
# it. The effect will be the same, as long as the Django test
393+
# runner doesn't add any global warning filters (it currently
394+
# does not).
395+
warnings.resetwarnings()
396+
369397
class DBCacheTests(unittest.TestCase, BaseCacheTests):
370398
def setUp(self):
371399
# Spaces are used in the table name to ensure quoting/escaping is working
@@ -397,6 +425,22 @@ class MemcachedCacheTests(unittest.TestCase, BaseCacheTests):
397425
def setUp(self):
398426
self.cache = get_cache(settings.CACHE_BACKEND)
399427

428+
def test_invalid_keys(self):
429+
"""
430+
On memcached, we don't introduce a duplicate key validation
431+
step (for speed reasons), we just let the memcached API
432+
library raise its own exception on bad keys. Refs #6447.
433+
434+
In order to be memcached-API-library agnostic, we only assert
435+
that a generic exception of some kind is raised.
436+
437+
"""
438+
# memcached does not allow whitespace or control characters in keys
439+
self.assertRaises(Exception, self.cache.set, 'key with spaces', 'value')
440+
# memcached limits key length to 250
441+
self.assertRaises(Exception, self.cache.set, 'a' * 251, 'value')
442+
443+
400444
class FileBasedCacheTests(unittest.TestCase, BaseCacheTests):
401445
"""
402446
Specific test cases for the file-based cache.
@@ -429,6 +473,22 @@ def test_subdirectory_removal(self):
429473
def test_cull(self):
430474
self.perform_cull_test(50, 28)
431475

476+
class CustomCacheKeyValidationTests(unittest.TestCase):
477+
"""
478+
Tests for the ability to mixin a custom ``validate_key`` method to
479+
a custom cache backend that otherwise inherits from a builtin
480+
backend, and override the default key validation. Refs #6447.
481+
482+
"""
483+
def test_custom_key_validation(self):
484+
cache = get_cache('regressiontests.cache.liberal_backend://')
485+
486+
# this key is both longer than 250 characters, and has spaces
487+
key = 'some key with spaces' * 15
488+
val = 'a value'
489+
cache.set(key, val)
490+
self.assertEqual(cache.get(key), val)
491+
432492
class CacheUtils(unittest.TestCase):
433493
"""TestCase for django.utils.cache functions."""
434494

0 commit comments

Comments
 (0)