From 3246ad106517e61437f80e8ef3c9d216754039e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20No=C3=A9?= Date: Fri, 27 Apr 2018 23:48:35 +0200 Subject: [PATCH] Fixed #27480 -- Added cache.touch(). --- django/core/cache/backends/base.py | 7 ++++++ django/core/cache/backends/db.py | 20 +++++++++++++++-- django/core/cache/backends/dummy.py | 4 ++++ django/core/cache/backends/filebased.py | 26 +++++++++++++++++++-- django/core/cache/backends/locmem.py | 8 +++++++ django/core/cache/backends/memcached.py | 10 +++++++++ docs/releases/2.1.txt | 3 +++ docs/topics/cache.txt | 17 ++++++++++++++ tests/cache/tests.py | 30 +++++++++++++++++++++++++ 9 files changed, 121 insertions(+), 4 deletions(-) diff --git a/django/core/cache/backends/base.py b/django/core/cache/backends/base.py index cf0df7cc68..0d0b1ddc82 100644 --- a/django/core/cache/backends/base.py +++ b/django/core/cache/backends/base.py @@ -124,6 +124,13 @@ class BaseCache: """ 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): """ Delete a key from the cache, failing silently. diff --git a/django/core/cache/backends/db.py b/django/core/cache/backends/db.py index cc92284709..d782f2191d 100644 --- a/django/core/cache/backends/db.py +++ b/django/core/cache/backends/db.py @@ -104,6 +104,11 @@ class DatabaseCache(BaseDatabaseCache): self.validate_key(key) 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): timeout = self.get_backend_timeout(timeout) db = router.db_for_write(self.cache_model_class) @@ -157,7 +162,16 @@ class DatabaseCache(BaseDatabaseCache): current_expires = converter(current_expires, expression, connection) 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( 'UPDATE %s SET %s = %%s, %s = %%s WHERE %s = %%s' % ( table, @@ -167,7 +181,7 @@ class DatabaseCache(BaseDatabaseCache): ), [b64encoded, exp, key] ) - else: + elif mode != 'touch': cursor.execute( 'INSERT INTO %s (%s, %s, %s) VALUES (%%s, %%s, %%s)' % ( table, @@ -177,6 +191,8 @@ class DatabaseCache(BaseDatabaseCache): ), [key, b64encoded, exp] ) + else: + return False # touch failed. except DatabaseError: # To be threadsafe, updates/inserts are allowed to fail silently return False diff --git a/django/core/cache/backends/dummy.py b/django/core/cache/backends/dummy.py index 4de2485be6..942b72ef89 100644 --- a/django/core/cache/backends/dummy.py +++ b/django/core/cache/backends/dummy.py @@ -21,6 +21,10 @@ class DummyCache(BaseCache): key = self.make_key(key, version=version) 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): key = self.make_key(key, version=version) self.validate_key(key) diff --git a/django/core/cache/backends/filebased.py b/django/core/cache/backends/filebased.py index e4a363dcd7..287d6472a5 100644 --- a/django/core/cache/backends/filebased.py +++ b/django/core/cache/backends/filebased.py @@ -9,9 +9,15 @@ import time import zlib 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 +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): cache_suffix = '.djcache' @@ -45,14 +51,30 @@ class FileBasedCache(BaseCache): try: with open(fd, 'wb') as f: expiry = self.get_backend_timeout(timeout) - f.write(pickle.dumps(expiry, pickle.HIGHEST_PROTOCOL)) - f.write(zlib.compress(pickle.dumps(value, pickle.HIGHEST_PROTOCOL))) + _write_content(f, expiry, value) file_move_safe(tmp_path, fname, allow_overwrite=True) renamed = True finally: if not renamed: 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): self._delete(self._key_to_file(key, version)) diff --git a/django/core/cache/backends/locmem.py b/django/core/cache/backends/locmem.py index cd610bbb73..093144f04a 100644 --- a/django/core/cache/backends/locmem.py +++ b/django/core/cache/backends/locmem.py @@ -55,6 +55,14 @@ class LocMemCache(BaseCache): with self._lock: 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): key = self.make_key(key, version=version) self.validate_key(key) diff --git a/django/core/cache/backends/memcached.py b/django/core/cache/backends/memcached.py index faaa36306f..1c8066ba03 100644 --- a/django/core/cache/backends/memcached.py +++ b/django/core/cache/backends/memcached.py @@ -162,6 +162,10 @@ class MemcachedCache(BaseMemcachedCache): self._client = self._lib.Client(self._servers, **client_kwargs) 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): "An implementation of a cache binding using pylibmc" @@ -173,6 +177,12 @@ class PyLibMCCache(BaseMemcachedCache): def _cache(self): 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): # libmemcached manages its own connections. Don't call disconnect_all() # as it resets the failover state and creates unnecessary reconnects. diff --git a/docs/releases/2.1.txt b/docs/releases/2.1.txt index 7595be46c0..e6b8b29a64 100644 --- a/docs/releases/2.1.txt +++ b/docs/releases/2.1.txt @@ -141,6 +141,9 @@ Cache * The :ref:`local-memory cache backend ` now uses a least-recently-used (LRU) culling strategy rather than a pseudo-random one. +* The new ``touch()`` method of the :ref:`low-level cache API + ` updates the timeout of cache keys. + CSRF ~~~~ diff --git a/docs/topics/cache.txt b/docs/topics/cache.txt index 8c5630f3e2..3ca3a08a4a 100644 --- a/docs/topics/cache.txt +++ b/docs/topics/cache.txt @@ -734,6 +734,7 @@ a cached item, for example: >>> key = make_template_fragment_key('sidebar', [username]) >>> cache.delete(key) # invalidates cached template fragment +.. _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.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 ``incr()`` or ``decr()`` methods, respectively. By default, the existing cache value will be incremented or decremented by 1. Other increment/decrement values diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 95fa5d68e6..890f8b6a9e 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -141,6 +141,10 @@ class DummyCacheTests(SimpleTestCase): with self.assertRaises(ValueError): 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): "All data types are ignored equally by the dummy cache" stuff = { @@ -427,6 +431,23 @@ class BaseCacheTests: self.assertEqual(cache.get("expire2"), "newvalue") 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): # Unicode values can be cached stuff = { @@ -549,6 +570,11 @@ class BaseCacheTests: self.assertEqual(cache.get('key3'), 'sausage') 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): """ 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('key4')) + cache.set('key5', 'belgian fries', timeout=5) + cache.touch('key5', timeout=0) + self.assertIsNone(cache.get('key5')) + def test_float_timeout(self): # Make sure a timeout given as a float doesn't crash anything. cache.set("key1", "spam", 100.2)