Fixed #27480 -- Added cache.touch().

This commit is contained in:
Nicolas Noé 2018-04-27 23:48:35 +02:00 committed by Tim Graham
parent 8e960c5aba
commit 3246ad1065
9 changed files with 121 additions and 4 deletions

View File

@ -124,6 +124,13 @@ class BaseCache:
""" """
raise NotImplementedError('subclasses of BaseCache must provide a set() method') raise NotImplementedError('subclasses of BaseCache must provide a set() method')
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
"""
Update the key's expiry time using timeout. Return True if successful
or False if the key does not exist.
"""
raise NotImplementedError('subclasses of BaseCache must provide a touch() method')
def delete(self, key, version=None): def delete(self, key, version=None):
""" """
Delete a key from the cache, failing silently. Delete a key from the cache, failing silently.

View File

@ -104,6 +104,11 @@ class DatabaseCache(BaseDatabaseCache):
self.validate_key(key) self.validate_key(key)
return self._base_set('add', key, value, timeout) return self._base_set('add', key, value, timeout)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
key = self.make_key(key, version=version)
self.validate_key(key)
return self._base_set('touch', key, None, timeout)
def _base_set(self, mode, key, value, timeout=DEFAULT_TIMEOUT): def _base_set(self, mode, key, value, timeout=DEFAULT_TIMEOUT):
timeout = self.get_backend_timeout(timeout) timeout = self.get_backend_timeout(timeout)
db = router.db_for_write(self.cache_model_class) db = router.db_for_write(self.cache_model_class)
@ -157,7 +162,16 @@ class DatabaseCache(BaseDatabaseCache):
current_expires = converter(current_expires, expression, connection) current_expires = converter(current_expires, expression, connection)
exp = connection.ops.adapt_datetimefield_value(exp) exp = connection.ops.adapt_datetimefield_value(exp)
if result and (mode == 'set' or (mode == 'add' and current_expires < now)): if result and mode == 'touch':
cursor.execute(
'UPDATE %s SET %s = %%s WHERE %s = %%s' % (
table,
quote_name('expires'),
quote_name('cache_key')
),
[exp, key]
)
elif result and (mode == 'set' or (mode == 'add' and current_expires < now)):
cursor.execute( cursor.execute(
'UPDATE %s SET %s = %%s, %s = %%s WHERE %s = %%s' % ( 'UPDATE %s SET %s = %%s, %s = %%s WHERE %s = %%s' % (
table, table,
@ -167,7 +181,7 @@ class DatabaseCache(BaseDatabaseCache):
), ),
[b64encoded, exp, key] [b64encoded, exp, key]
) )
else: elif mode != 'touch':
cursor.execute( cursor.execute(
'INSERT INTO %s (%s, %s, %s) VALUES (%%s, %%s, %%s)' % ( 'INSERT INTO %s (%s, %s, %s) VALUES (%%s, %%s, %%s)' % (
table, table,
@ -177,6 +191,8 @@ class DatabaseCache(BaseDatabaseCache):
), ),
[key, b64encoded, exp] [key, b64encoded, exp]
) )
else:
return False # touch failed.
except DatabaseError: except DatabaseError:
# To be threadsafe, updates/inserts are allowed to fail silently # To be threadsafe, updates/inserts are allowed to fail silently
return False return False

View File

@ -21,6 +21,10 @@ class DummyCache(BaseCache):
key = self.make_key(key, version=version) key = self.make_key(key, version=version)
self.validate_key(key) self.validate_key(key)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
self.validate_key(key)
return False
def delete(self, key, version=None): def delete(self, key, version=None):
key = self.make_key(key, version=version) key = self.make_key(key, version=version)
self.validate_key(key) self.validate_key(key)

View File

@ -9,9 +9,15 @@ import time
import zlib import zlib
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache
from django.core.files import locks
from django.core.files.move import file_move_safe from django.core.files.move import file_move_safe
def _write_content(f, expiry, value):
f.write(pickle.dumps(expiry, pickle.HIGHEST_PROTOCOL))
f.write(zlib.compress(pickle.dumps(value, pickle.HIGHEST_PROTOCOL)))
class FileBasedCache(BaseCache): class FileBasedCache(BaseCache):
cache_suffix = '.djcache' cache_suffix = '.djcache'
@ -45,14 +51,30 @@ class FileBasedCache(BaseCache):
try: try:
with open(fd, 'wb') as f: with open(fd, 'wb') as f:
expiry = self.get_backend_timeout(timeout) expiry = self.get_backend_timeout(timeout)
f.write(pickle.dumps(expiry, pickle.HIGHEST_PROTOCOL)) _write_content(f, expiry, value)
f.write(zlib.compress(pickle.dumps(value, pickle.HIGHEST_PROTOCOL)))
file_move_safe(tmp_path, fname, allow_overwrite=True) file_move_safe(tmp_path, fname, allow_overwrite=True)
renamed = True renamed = True
finally: finally:
if not renamed: if not renamed:
os.remove(tmp_path) os.remove(tmp_path)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
try:
with open(self._key_to_file(key, version), 'r+b') as f:
try:
locks.lock(f, locks.LOCK_EX)
if self._is_expired(f):
return False
else:
previous_value = pickle.loads(zlib.decompress(f.read()))
f.seek(0)
_write_content(f, self.get_backend_timeout(timeout), previous_value)
return True
finally:
locks.unlock(f)
except FileNotFoundError:
return False
def delete(self, key, version=None): def delete(self, key, version=None):
self._delete(self._key_to_file(key, version)) self._delete(self._key_to_file(key, version))

View File

@ -55,6 +55,14 @@ class LocMemCache(BaseCache):
with self._lock: with self._lock:
self._set(key, pickled, timeout) self._set(key, pickled, timeout)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
key = self.make_key(key, version=version)
with self._lock:
if self._has_expired(key):
return False
self._expire_info[key] = self.get_backend_timeout(timeout)
return True
def incr(self, key, delta=1, version=None): def incr(self, key, delta=1, version=None):
key = self.make_key(key, version=version) key = self.make_key(key, version=version)
self.validate_key(key) self.validate_key(key)

View File

@ -162,6 +162,10 @@ class MemcachedCache(BaseMemcachedCache):
self._client = self._lib.Client(self._servers, **client_kwargs) self._client = self._lib.Client(self._servers, **client_kwargs)
return self._client return self._client
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
key = self.make_key(key, version=version)
return self._cache.touch(key, self.get_backend_timeout(timeout)) != 0
class PyLibMCCache(BaseMemcachedCache): class PyLibMCCache(BaseMemcachedCache):
"An implementation of a cache binding using pylibmc" "An implementation of a cache binding using pylibmc"
@ -173,6 +177,12 @@ class PyLibMCCache(BaseMemcachedCache):
def _cache(self): def _cache(self):
return self._lib.Client(self._servers, **self._options) return self._lib.Client(self._servers, **self._options)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
key = self.make_key(key, version=version)
if timeout == 0:
return self._cache.delete(key)
return self._cache.touch(key, self.get_backend_timeout(timeout))
def close(self, **kwargs): def close(self, **kwargs):
# libmemcached manages its own connections. Don't call disconnect_all() # libmemcached manages its own connections. Don't call disconnect_all()
# as it resets the failover state and creates unnecessary reconnects. # as it resets the failover state and creates unnecessary reconnects.

View File

@ -141,6 +141,9 @@ Cache
* The :ref:`local-memory cache backend <local-memory-caching>` now uses a * The :ref:`local-memory cache backend <local-memory-caching>` now uses a
least-recently-used (LRU) culling strategy rather than a pseudo-random one. least-recently-used (LRU) culling strategy rather than a pseudo-random one.
* The new ``touch()`` method of the :ref:`low-level cache API
<low-level-cache-api>` updates the timeout of cache keys.
CSRF CSRF
~~~~ ~~~~

View File

@ -734,6 +734,7 @@ a cached item, for example:
>>> key = make_template_fragment_key('sidebar', [username]) >>> key = make_template_fragment_key('sidebar', [username])
>>> cache.delete(key) # invalidates cached template fragment >>> cache.delete(key) # invalidates cached template fragment
.. _low-level-cache-api:
The low-level cache API The low-level cache API
======================= =======================
@ -891,6 +892,22 @@ from the cache, not just the keys set by your application. ::
>>> cache.clear() >>> cache.clear()
``cache.touch()`` sets a new expiration for a key. For example, to update a key
to expire 10 seconds from now::
>>> cache.touch('a', 10)
True
Like other methods, the ``timeout`` argument is optional and defaults to the
``TIMEOUT`` option of the appropriate backend in the :setting:`CACHES` setting.
``touch()`` returns ``True`` if the key was successfully touched, ``False``
otherwise.
.. versionchanged:: 2.1
The ``cache.touch()`` method was added.
You can also increment or decrement a key that already exists using the You can also increment or decrement a key that already exists using the
``incr()`` or ``decr()`` methods, respectively. By default, the existing cache ``incr()`` or ``decr()`` methods, respectively. By default, the existing cache
value will be incremented or decremented by 1. Other increment/decrement values value will be incremented or decremented by 1. Other increment/decrement values

30
tests/cache/tests.py vendored
View File

@ -141,6 +141,10 @@ class DummyCacheTests(SimpleTestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
cache.decr('does_not_exist') cache.decr('does_not_exist')
def test_touch(self):
"""Dummy cache can't do touch()."""
self.assertIs(cache.touch('whatever'), False)
def test_data_types(self): def test_data_types(self):
"All data types are ignored equally by the dummy cache" "All data types are ignored equally by the dummy cache"
stuff = { stuff = {
@ -427,6 +431,23 @@ class BaseCacheTests:
self.assertEqual(cache.get("expire2"), "newvalue") self.assertEqual(cache.get("expire2"), "newvalue")
self.assertFalse(cache.has_key("expire3")) self.assertFalse(cache.has_key("expire3"))
def test_touch(self):
# cache.touch() updates the timeout.
cache.set('expire1', 'very quickly', timeout=1)
self.assertIs(cache.touch('expire1', timeout=4), True)
time.sleep(2)
self.assertTrue(cache.has_key('expire1'))
time.sleep(3)
self.assertFalse(cache.has_key('expire1'))
# cache.touch() works without the timeout argument.
cache.set('expire1', 'very quickly', timeout=1)
self.assertIs(cache.touch('expire1'), True)
time.sleep(2)
self.assertTrue(cache.has_key('expire1'))
self.assertIs(cache.touch('nonexistent'), False)
def test_unicode(self): def test_unicode(self):
# Unicode values can be cached # Unicode values can be cached
stuff = { stuff = {
@ -549,6 +570,11 @@ class BaseCacheTests:
self.assertEqual(cache.get('key3'), 'sausage') self.assertEqual(cache.get('key3'), 'sausage')
self.assertEqual(cache.get('key4'), 'lobster bisque') self.assertEqual(cache.get('key4'), 'lobster bisque')
cache.set('key5', 'belgian fries', timeout=1)
cache.touch('key5', timeout=None)
time.sleep(2)
self.assertEqual(cache.get('key5'), 'belgian fries')
def test_zero_timeout(self): def test_zero_timeout(self):
""" """
Passing in zero into timeout results in a value that is not cached Passing in zero into timeout results in a value that is not cached
@ -563,6 +589,10 @@ class BaseCacheTests:
self.assertIsNone(cache.get('key3')) self.assertIsNone(cache.get('key3'))
self.assertIsNone(cache.get('key4')) self.assertIsNone(cache.get('key4'))
cache.set('key5', 'belgian fries', timeout=5)
cache.touch('key5', timeout=0)
self.assertIsNone(cache.get('key5'))
def test_float_timeout(self): def test_float_timeout(self):
# Make sure a timeout given as a float doesn't crash anything. # Make sure a timeout given as a float doesn't crash anything.
cache.set("key1", "spam", 100.2) cache.set("key1", "spam", 100.2)