Fixed #32076 -- Added async methods to BaseCache.

This also makes DummyCache async-compatible.
This commit is contained in:
Andrew-Chen-Wang 2020-10-16 10:16:55 -04:00 committed by Mariusz Felisiak
parent 42dfa97e19
commit 301a85a12f
4 changed files with 312 additions and 1 deletions

View File

@ -2,6 +2,8 @@
import time import time
import warnings import warnings
from asgiref.sync import sync_to_async
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
@ -130,6 +132,9 @@ class BaseCache:
""" """
raise NotImplementedError('subclasses of BaseCache must provide an add() method') raise NotImplementedError('subclasses of BaseCache must provide an add() method')
async def aadd(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
return await sync_to_async(self.add, thread_sensitive=True)(key, value, timeout, version)
def get(self, key, default=None, version=None): def get(self, key, default=None, version=None):
""" """
Fetch a given key from the cache. If the key does not exist, return Fetch a given key from the cache. If the key does not exist, return
@ -137,6 +142,9 @@ class BaseCache:
""" """
raise NotImplementedError('subclasses of BaseCache must provide a get() method') raise NotImplementedError('subclasses of BaseCache must provide a get() method')
async def aget(self, key, default=None, version=None):
return await sync_to_async(self.get, thread_sensitive=True)(key, default, version)
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
""" """
Set a value in the cache. If timeout is given, use that timeout for the Set a value in the cache. If timeout is given, use that timeout for the
@ -144,6 +152,9 @@ class BaseCache:
""" """
raise NotImplementedError('subclasses of BaseCache must provide a set() method') raise NotImplementedError('subclasses of BaseCache must provide a set() method')
async def aset(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
return await sync_to_async(self.set, thread_sensitive=True)(key, value, timeout, version)
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None): def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
""" """
Update the key's expiry time using timeout. Return True if successful Update the key's expiry time using timeout. Return True if successful
@ -151,6 +162,9 @@ class BaseCache:
""" """
raise NotImplementedError('subclasses of BaseCache must provide a touch() method') raise NotImplementedError('subclasses of BaseCache must provide a touch() method')
async def atouch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
return await sync_to_async(self.touch, thread_sensitive=True)(key, timeout, version)
def delete(self, key, version=None): def delete(self, key, version=None):
""" """
Delete a key from the cache and return whether it succeeded, failing Delete a key from the cache and return whether it succeeded, failing
@ -158,6 +172,9 @@ class BaseCache:
""" """
raise NotImplementedError('subclasses of BaseCache must provide a delete() method') raise NotImplementedError('subclasses of BaseCache must provide a delete() method')
async def adelete(self, key, version=None):
return await sync_to_async(self.delete, thread_sensitive=True)(key, version)
def get_many(self, keys, version=None): def get_many(self, keys, version=None):
""" """
Fetch a bunch of keys from the cache. For certain backends (memcached, Fetch a bunch of keys from the cache. For certain backends (memcached,
@ -173,6 +190,15 @@ class BaseCache:
d[k] = val d[k] = val
return d return d
async def aget_many(self, keys, version=None):
"""See get_many()."""
d = {}
for k in keys:
val = await self.aget(k, self._missing_key, version=version)
if val is not self._missing_key:
d[k] = val
return d
def get_or_set(self, key, default, timeout=DEFAULT_TIMEOUT, version=None): def get_or_set(self, key, default, timeout=DEFAULT_TIMEOUT, version=None):
""" """
Fetch a given key from the cache. If the key does not exist, Fetch a given key from the cache. If the key does not exist,
@ -192,12 +218,30 @@ class BaseCache:
return self.get(key, default, version=version) return self.get(key, default, version=version)
return val return val
async def aget_or_set(self, key, default, timeout=DEFAULT_TIMEOUT, version=None):
"""See get_or_set()."""
val = await self.aget(key, self._missing_key, version=version)
if val is self._missing_key:
if callable(default):
default = default()
await self.aadd(key, default, timeout=timeout, version=version)
# Fetch the value again to avoid a race condition if another caller
# added a value between the first aget() and the aadd() above.
return await self.aget(key, default, version=version)
return val
def has_key(self, key, version=None): def has_key(self, key, version=None):
""" """
Return True if the key is in the cache and has not expired. Return True if the key is in the cache and has not expired.
""" """
return self.get(key, self._missing_key, version=version) is not self._missing_key return self.get(key, self._missing_key, version=version) is not self._missing_key
async def ahas_key(self, key, version=None):
return (
await self.aget(key, self._missing_key, version=version)
is not self._missing_key
)
def incr(self, key, delta=1, version=None): def incr(self, key, delta=1, version=None):
""" """
Add delta to value in the cache. If the key does not exist, raise a Add delta to value in the cache. If the key does not exist, raise a
@ -210,6 +254,15 @@ class BaseCache:
self.set(key, new_value, version=version) self.set(key, new_value, version=version)
return new_value return new_value
async def aincr(self, key, delta=1, version=None):
"""See incr()."""
value = await self.aget(key, self._missing_key, version=version)
if value is self._missing_key:
raise ValueError("Key '%s' not found" % key)
new_value = value + delta
await self.aset(key, new_value, version=version)
return new_value
def decr(self, key, delta=1, version=None): def decr(self, key, delta=1, version=None):
""" """
Subtract delta from value in the cache. If the key does not exist, raise Subtract delta from value in the cache. If the key does not exist, raise
@ -217,6 +270,9 @@ class BaseCache:
""" """
return self.incr(key, -delta, version=version) return self.incr(key, -delta, version=version)
async def adecr(self, key, delta=1, version=None):
return await self.aincr(key, -delta, version=version)
def __contains__(self, key): def __contains__(self, key):
""" """
Return True if the key is in the cache and has not expired. Return True if the key is in the cache and has not expired.
@ -242,6 +298,11 @@ class BaseCache:
self.set(key, value, timeout=timeout, version=version) self.set(key, value, timeout=timeout, version=version)
return [] return []
async def aset_many(self, data, timeout=DEFAULT_TIMEOUT, version=None):
for key, value in data.items():
await self.aset(key, value, timeout=timeout, version=version)
return []
def delete_many(self, keys, version=None): def delete_many(self, keys, version=None):
""" """
Delete a bunch of values in the cache at once. For certain backends Delete a bunch of values in the cache at once. For certain backends
@ -251,10 +312,17 @@ class BaseCache:
for key in keys: for key in keys:
self.delete(key, version=version) self.delete(key, version=version)
async def adelete_many(self, keys, version=None):
for key in keys:
await self.adelete(key, version=version)
def clear(self): def clear(self):
"""Remove *all* values from the cache at once.""" """Remove *all* values from the cache at once."""
raise NotImplementedError('subclasses of BaseCache must provide a clear() method') raise NotImplementedError('subclasses of BaseCache must provide a clear() method')
async def aclear(self):
return await sync_to_async(self.clear, thread_sensitive=True)()
def incr_version(self, key, delta=1, version=None): def incr_version(self, key, delta=1, version=None):
""" """
Add delta to the cache version for the supplied key. Return the new Add delta to the cache version for the supplied key. Return the new
@ -271,6 +339,19 @@ class BaseCache:
self.delete(key, version=version) self.delete(key, version=version)
return version + delta return version + delta
async def aincr_version(self, key, delta=1, version=None):
"""See incr_version()."""
if version is None:
version = self.version
value = await self.aget(key, self._missing_key, version=version)
if value is self._missing_key:
raise ValueError("Key '%s' not found" % key)
await self.aset(key, value, version=version + delta)
await self.adelete(key, version=version)
return version + delta
def decr_version(self, key, delta=1, version=None): def decr_version(self, key, delta=1, version=None):
""" """
Subtract delta from the cache version for the supplied key. Return the Subtract delta from the cache version for the supplied key. Return the
@ -278,10 +359,16 @@ class BaseCache:
""" """
return self.incr_version(key, -delta, version) return self.incr_version(key, -delta, version)
async def adecr_version(self, key, delta=1, version=None):
return await self.aincr_version(key, -delta, version)
def close(self, **kwargs): def close(self, **kwargs):
"""Close the cache connection""" """Close the cache connection"""
pass pass
async def aclose(self, **kwargs):
pass
def memcache_key_warnings(key): def memcache_key_warnings(key):
if len(key) > MEMCACHE_MAX_KEY_LENGTH: if len(key) > MEMCACHE_MAX_KEY_LENGTH:

View File

@ -187,7 +187,13 @@ Minor features
Cache Cache
~~~~~ ~~~~~
* ... * The new async API for ``django.core.cache.backends.base.BaseCache`` begins
the process of making cache backends async-compatible. The new async methods
all have ``a`` prefixed names, e.g. ``aadd()``, ``aget()``, ``aset()``,
``aget_or_set()``, or ``adelete_many()``.
Going forward, the ``a`` prefix will be used for async variants of methods
generally.
CSRF CSRF
~~~~ ~~~~

View File

@ -808,6 +808,8 @@ Accessing the cache
This object is equivalent to ``caches['default']``. This object is equivalent to ``caches['default']``.
.. _cache-basic-interface:
Basic usage Basic usage
----------- -----------
@ -997,6 +999,16 @@ the cache backend.
For caches that don't implement ``close`` methods it is a no-op. For caches that don't implement ``close`` methods it is a no-op.
.. note::
The async variants of base methods are prefixed with ``a``, e.g.
``cache.aadd()`` or ``cache.adelete_many()``. See `Asynchronous support`_
for more details.
.. versionchanged:: 4.0
The async variants of methods were added to the ``BaseCache``.
.. _cache_key_prefixing: .. _cache_key_prefixing:
Cache key prefixing Cache key prefixing
@ -1123,6 +1135,25 @@ instance, to do this for the ``locmem`` backend, put this code in a module::
...and use the dotted Python path to this class in the ...and use the dotted Python path to this class in the
:setting:`BACKEND <CACHES-BACKEND>` portion of your :setting:`CACHES` setting. :setting:`BACKEND <CACHES-BACKEND>` portion of your :setting:`CACHES` setting.
.. _asynchronous_support:
Asynchronous support
====================
.. versionadded:: 4.0
Django has developing support for asynchronous cache backends, but does not
yet support asynchronous caching. It will be coming in a future release.
``django.core.cache.backends.base.BaseCache`` has async variants of :ref:`all
base methods <cache-basic-interface>`. By convention, the asynchronous versions
of all methods are prefixed with ``a``. By default, the arguments for both
variants are the same::
>>> await cache.aset('num', 1)
>>> await cache.ahas_key('num')
True
.. _downstream-caches: .. _downstream-caches:
Downstream caches Downstream caches

187
tests/cache/tests_async.py vendored Normal file
View File

@ -0,0 +1,187 @@
import asyncio
from django.core.cache import CacheKeyWarning, cache
from django.test import SimpleTestCase, override_settings
from .tests import KEY_ERRORS_WITH_MEMCACHED_MSG
@override_settings(CACHES={
'default': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
}
})
class AsyncDummyCacheTests(SimpleTestCase):
async def test_simple(self):
"""Dummy cache backend ignores cache set calls."""
await cache.aset('key', 'value')
self.assertIsNone(await cache.aget('key'))
async def test_aadd(self):
"""Add doesn't do anything in dummy cache backend."""
self.assertIs(await cache.aadd('key', 'value'), True)
self.assertIs(await cache.aadd('key', 'new_value'), True)
self.assertIsNone(await cache.aget('key'))
async def test_non_existent(self):
"""Nonexistent keys aren't found in the dummy cache backend."""
self.assertIsNone(await cache.aget('does_not_exist'))
self.assertEqual(await cache.aget('does_not_exist', 'default'), 'default')
async def test_aget_many(self):
"""aget_many() returns nothing for the dummy cache backend."""
await cache.aset_many({'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd'})
self.assertEqual(await cache.aget_many(['a', 'c', 'd']), {})
self.assertEqual(await cache.aget_many(['a', 'b', 'e']), {})
async def test_aget_many_invalid_key(self):
msg = KEY_ERRORS_WITH_MEMCACHED_MSG % ':1:key with spaces'
with self.assertWarnsMessage(CacheKeyWarning, msg):
await cache.aget_many(['key with spaces'])
async def test_adelete(self):
"""
Cache deletion is transparently ignored on the dummy cache backend.
"""
await cache.aset_many({'key1': 'spam', 'key2': 'eggs'})
self.assertIsNone(await cache.aget('key1'))
self.assertIs(await cache.adelete('key1'), False)
self.assertIsNone(await cache.aget('key1'))
self.assertIsNone(await cache.aget('key2'))
async def test_ahas_key(self):
"""ahas_key() doesn't ever return True for the dummy cache backend."""
await cache.aset('hello1', 'goodbye1')
self.assertIs(await cache.ahas_key('hello1'), False)
self.assertIs(await cache.ahas_key('goodbye1'), False)
async def test_aincr(self):
"""Dummy cache values can't be incremented."""
await cache.aset('answer', 42)
with self.assertRaises(ValueError):
await cache.aincr('answer')
with self.assertRaises(ValueError):
await cache.aincr('does_not_exist')
with self.assertRaises(ValueError):
await cache.aincr('does_not_exist', -1)
async def test_adecr(self):
"""Dummy cache values can't be decremented."""
await cache.aset('answer', 42)
with self.assertRaises(ValueError):
await cache.adecr('answer')
with self.assertRaises(ValueError):
await cache.adecr('does_not_exist')
with self.assertRaises(ValueError):
await cache.adecr('does_not_exist', -1)
async def test_atouch(self):
self.assertIs(await cache.atouch('key'), False)
async def test_data_types(self):
"""All data types are ignored equally by the dummy cache."""
def f():
return 42
class C:
def m(n):
return 24
data = {
'string': 'this is a string',
'int': 42,
'list': [1, 2, 3, 4],
'tuple': (1, 2, 3, 4),
'dict': {'A': 1, 'B': 2},
'function': f,
'class': C,
}
await cache.aset('data', data)
self.assertIsNone(await cache.aget('data'))
async def test_expiration(self):
"""Expiration has no effect on the dummy cache."""
await cache.aset('expire1', 'very quickly', 1)
await cache.aset('expire2', 'very quickly', 1)
await cache.aset('expire3', 'very quickly', 1)
await asyncio.sleep(2)
self.assertIsNone(await cache.aget('expire1'))
self.assertIs(await cache.aadd('expire2', 'new_value'), True)
self.assertIsNone(await cache.aget('expire2'))
self.assertIs(await cache.ahas_key('expire3'), False)
async def test_unicode(self):
"""Unicode values are ignored by the dummy cache."""
tests = {
'ascii': 'ascii_value',
'unicode_ascii': 'Iñtërnâtiônàlizætiøn1',
'Iñtërnâtiônàlizætiøn': 'Iñtërnâtiônàlizætiøn2',
'ascii2': {'x': 1},
}
for key, value in tests.items():
with self.subTest(key=key):
await cache.aset(key, value)
self.assertIsNone(await cache.aget(key))
async def test_aset_many(self):
"""aset_many() does nothing for the dummy cache backend."""
self.assertEqual(await cache.aset_many({'a': 1, 'b': 2}), [])
self.assertEqual(
await cache.aset_many({'a': 1, 'b': 2}, timeout=2, version='1'),
[],
)
async def test_aset_many_invalid_key(self):
msg = KEY_ERRORS_WITH_MEMCACHED_MSG % ':1:key with spaces'
with self.assertWarnsMessage(CacheKeyWarning, msg):
await cache.aset_many({'key with spaces': 'foo'})
async def test_adelete_many(self):
"""adelete_many() does nothing for the dummy cache backend."""
await cache.adelete_many(['a', 'b'])
async def test_adelete_many_invalid_key(self):
msg = KEY_ERRORS_WITH_MEMCACHED_MSG % ':1:key with spaces'
with self.assertWarnsMessage(CacheKeyWarning, msg):
await cache.adelete_many({'key with spaces': 'foo'})
async def test_aclear(self):
"""aclear() does nothing for the dummy cache backend."""
await cache.aclear()
async def test_aclose(self):
"""aclose() does nothing for the dummy cache backend."""
await cache.aclose()
async def test_aincr_version(self):
"""Dummy cache versions can't be incremented."""
await cache.aset('answer', 42)
with self.assertRaises(ValueError):
await cache.aincr_version('answer')
with self.assertRaises(ValueError):
await cache.aincr_version('answer', version=2)
with self.assertRaises(ValueError):
await cache.aincr_version('does_not_exist')
async def test_adecr_version(self):
"""Dummy cache versions can't be decremented."""
await cache.aset('answer', 42)
with self.assertRaises(ValueError):
await cache.adecr_version('answer')
with self.assertRaises(ValueError):
await cache.adecr_version('answer', version=2)
with self.assertRaises(ValueError):
await cache.adecr_version('does_not_exist')
async def test_aget_or_set(self):
self.assertEqual(await cache.aget_or_set('key', 'default'), 'default')
self.assertIsNone(await cache.aget_or_set('key', None))
async def test_aget_or_set_callable(self):
def my_callable():
return 'default'
self.assertEqual(await cache.aget_or_set('key', my_callable), 'default')
self.assertEqual(await cache.aget_or_set('key', my_callable()), 'default')