diff --git a/deployment/docker/standalone/Dockerfile b/deployment/docker/standalone/Dockerfile index 3915fce4fe..1969e1ea0d 100644 --- a/deployment/docker/standalone/Dockerfile +++ b/deployment/docker/standalone/Dockerfile @@ -27,6 +27,7 @@ RUN pip3 install -r requirements.txt RUN pip3 install -r requirements/mysql.txt RUN pip3 install -r requirements/postgres.txt RUN pip3 install -r requirements/memcached.txt +RUN pip3 install -r requirements/redis.txt RUN pip3 install gunicorn RUN make diff --git a/doc/admin/config.rst b/doc/admin/config.rst index 04b562d523..ce723e8f1c 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -177,4 +177,25 @@ You can use an existing memcached server as pretix's caching backend:: If no memcached is configures, pretix will use Django's built-in local-memory caching method. + +Redis +----- + +If a redis server is configured, pretix can use it for locking, caching and session storage +to speed up various operations:: + + [redis] + location=redis://127.0.0.1:6379/1 + sessions=false + +``location`` + The location of memcached, as an URL of the form ``redis://[:password]@localhost:6379/0`` + or ``unix://[:password]@/path/to/socket.sock?db=0`` + +``session`` + When this is set to true, redis will be used as the session storage. + +If no redis is configured, pretix will store sessions and locks in the database. If memcached +is configured, memcached will be used for caching instead of redis. + .. _Python documentation: https://docs.python.org/3/library/configparser.html?highlight=configparser#supported-ini-file-structure \ No newline at end of file diff --git a/src/pretix/base/models.py b/src/pretix/base/models.py index 4bda0b2e03..96a06a164f 100644 --- a/src/pretix/base/models.py +++ b/src/pretix/base/models.py @@ -1408,22 +1408,8 @@ class Quota(Versionable): :raises Quota.LockTimeoutException: if the quota is locked every time we try to obtain the lock """ - retries = 5 - for i in range(retries): - dt = now() - updated = Quota.objects.current.filter( - Q(identity=self.identity) - & Q(Q(locked__lt=dt - timedelta(seconds=120)) | Q(locked__isnull=True)) - & Q(version_end_date__isnull=True) - ).update( - locked=dt - ) - if updated: - self.locked_here = dt - self.locked = dt - return True - time.sleep(2 ** i / 100) - raise Quota.LockTimeoutException() + from .services import locking + return locking.lock_quota(self) def release(self, force=False): """ @@ -1431,17 +1417,8 @@ class Quota(Versionable): the lock will only be released if it was issued in _this_ python representation of the database object. """ - if not self.locked_here and not force: - return False - updated = Quota.objects.current.filter( - identity=self.identity, - version_end_date__isnull=True - ).update( - locked=None - ) - self.locked_here = None - self.locked = None - return updated + from .services import locking + return locking.release_quota(self, force) class Order(Versionable): diff --git a/src/pretix/base/services/locking.py b/src/pretix/base/services/locking.py new file mode 100644 index 0000000000..1cc2b42c6e --- /dev/null +++ b/src/pretix/base/services/locking.py @@ -0,0 +1,111 @@ +from datetime import timedelta +import logging +import time +from django.db.models import Q +from django.utils.timezone import now +from pretix import settings + +from pretix.base.models import Quota +from redis import RedisError + +logger = logging.getLogger('pretix.base.locking') + + +def lock_quota(quota): + """ + Issue a lock on this quota so nobody can take tickets from this quota until + you release the lock. Will retry 5 times on failure. + + :raises Quota.LockTimeoutException: if the quota is locked every time we try + to obtain the lock + """ + if settings.HAS_REDIS: + return lock_quota_redis(quota) + else: + return lock_quota_db(quota) + + +def lock_quota_db(quota): + retries = 5 + for i in range(retries): + dt = now() + + updated = Quota.objects.current.filter( + Q(identity=quota.identity) + & Q(Q(locked__lt=dt - timedelta(seconds=120)) | Q(locked__isnull=True)) + & Q(version_end_date__isnull=True) + ).update( + locked=dt + ) + if updated: + quota.locked_here = dt + quota.locked = dt + return True + time.sleep(2 ** i / 100) + raise Quota.LockTimeoutException() + + +def release_quota(quota, force=False): + """ + Release a lock placed by :py:meth:`lock()`. If the parameter force is not set to ``True``, + the lock will only be released if it was issued in _this_ python + representation of the database object. + """ + if not quota.locked_here and not force: + return False + if settings.HAS_REDIS: + return release_quota_redis(quota) + else: + return release_quota_db(quota) + + +def release_quota_db(quota): + updated = Quota.objects.current.filter( + identity=quota.identity, + version_end_date__isnull=True + ).update( + locked=None + ) + quota.locked_here = None + quota.locked = None + return updated + + +def redis_lock_from_quota(quota): + from django_redis import get_redis_connection + from redis.lock import Lock + + if not hasattr(quota, '_redis_lock'): + rc = get_redis_connection("redis") + quota._redis_lock = Lock(redis=rc, name='pretix_quota_%s' % quota.identity, timeout=120) + return quota._redis_lock + + +def lock_quota_redis(quota): + from redis.exceptions import RedisError + lock = redis_lock_from_quota(quota) + retries = 5 + for i in range(retries): + dt = now() + try: + if lock.acquire(False): + quota.locked_here = dt + quota.locked = dt + return True + except RedisError: + logger.exception('Error locking a quota') + raise Quota.LockTimeoutException() + time.sleep(2 ** i / 100) + raise Quota.LockTimeoutException() + + +def release_quota_redis(quota): + lock = redis_lock_from_quota(quota) + try: + lock.release() + except RedisError: + logger.exception('Error releasing a quota lock') + raise Quota.LockTimeoutException() + quota.locked_here = None + quota.locked = None + return True diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 83cb8c701f..dd3a12bac0 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -76,13 +76,34 @@ SESSION_COOKIE_SECURE = SESSION_COOKIE_HTTPONLY = config.getboolean( LANGUAGE_COOKIE_DOMAIN = SESSION_COOKIE_DOMAIN = CSRF_COOKIE_DOMAIN = config.get( 'pretix', 'cookiedomain', fallback=None) -if config.has_option('memcached', 'location'): - CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', - 'LOCATION': config.get('memcached', 'location'), +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake', + } +} + +HAS_MEMCACHED = config.has_option('memcached', 'location') +if HAS_MEMCACHED: + CACHES['default'] = { + 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', + 'LOCATION': config.get('memcached', 'location'), + } + +HAS_REDIS = config.has_option('redis', 'location') +if HAS_REDIS: + CACHES['redis'] = { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": config.get('redis', 'location'), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", } } + if not HAS_MEMCACHED: + CACHES['default'] = CACHES['redis'] + if config.getboolean('redis', 'sessions', fallback=False): + SESSION_ENGINE = "django.contrib.sessions.backends.cache" + SESSION_CACHE_ALIAS = "redis" # Internal settings diff --git a/src/requirements/redis.txt b/src/requirements/redis.txt new file mode 100644 index 0000000000..11ae9e7154 --- /dev/null +++ b/src/requirements/redis.txt @@ -0,0 +1,2 @@ +django-redis>=4.1,<4.2 +redis>=2.10,<2.11