django-constance/constance/backends/database.py
Philipp Thumfart 4ac1e546c7
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
Added async support (#656)
* Added async logic

* Added tests and fixed async deadlock on aset

* Used abstract base class for backend to simplify code coverage

* Reordered try except block

* Added explicit thread safety

* Fixed linting error

* Worked on redis init block

* Fixed async test setup

* Added tests for redis instantiation

* Fixed linting errors
2026-03-04 16:37:37 -06:00

174 lines
6.5 KiB
Python

from django.core.cache import caches
from django.core.cache.backends.locmem import LocMemCache
from django.core.exceptions import ImproperlyConfigured
from django.db import IntegrityError
from django.db import OperationalError
from django.db import ProgrammingError
from django.db import transaction
from django.db.models.signals import post_save
from constance import config
from constance import settings
from constance import signals
from constance.backends import Backend
from constance.codecs import dumps
from constance.codecs import loads
class DatabaseBackend(Backend):
def __init__(self):
from constance.models import Constance
self._model = Constance
self._prefix = settings.DATABASE_PREFIX
self._autofill_timeout = settings.DATABASE_CACHE_AUTOFILL_TIMEOUT
self._autofill_cachekey = "autofilled"
if self._model._meta.app_config is None:
raise ImproperlyConfigured(
"The constance.backends.database app isn't installed "
"correctly. Make sure it's in your INSTALLED_APPS setting."
)
if settings.DATABASE_CACHE_BACKEND:
self._cache = caches[settings.DATABASE_CACHE_BACKEND]
if isinstance(self._cache, LocMemCache):
raise ImproperlyConfigured(
"The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a "
f"subclass of Django's local-memory backend ({settings.DATABASE_CACHE_BACKEND!r}). Please "
"set it to a backend that supports cross-process caching."
)
else:
self._cache = None
self.autofill()
# Clear simple cache.
post_save.connect(self.clear, sender=self._model)
def add_prefix(self, key):
return f"{self._prefix}{key}"
def autofill(self):
if not self._autofill_timeout or not self._cache:
return
full_cachekey = self.add_prefix(self._autofill_cachekey)
if self._cache.get(full_cachekey):
return
autofill_values = {full_cachekey: 1}
for key, value in self.mget(settings.CONFIG):
autofill_values[self.add_prefix(key)] = value
self._cache.set_many(autofill_values, timeout=self._autofill_timeout)
def mget(self, keys):
if not keys:
return
keys = {self.add_prefix(key): key for key in keys}
try:
stored = self._model._default_manager.filter(key__in=keys)
for const in stored:
yield keys[const.key], loads(const.value)
except (OperationalError, ProgrammingError):
pass
def get(self, key):
key = self.add_prefix(key)
value = None
if self._cache:
value = self._cache.get(key)
if value is None:
self.autofill()
value = self._cache.get(key)
if value is None:
match = self._model._default_manager.filter(key=key).only("value").first()
if match:
value = loads(match.value)
if self._cache:
self._cache.add(key, value)
return value
async def aget(self, key):
from asgiref.sync import sync_to_async
prefixed_key = self.add_prefix(key)
value = None
if self._cache:
value = await self._cache.aget(prefixed_key)
if value is None:
await sync_to_async(self.autofill, thread_sensitive=True)()
value = await self._cache.aget(prefixed_key)
if value is None:
match = await self._model._default_manager.filter(key=prefixed_key).only("value").afirst()
if match:
value = loads(match.value)
if self._cache:
await self._cache.aadd(prefixed_key, value)
return value
async def amget(self, keys):
if not keys:
return {}
prefixed_keys_map = {self.add_prefix(key): key for key in keys}
results = {}
if self._cache:
cache_results = await self._cache.aget_many(prefixed_keys_map.keys())
for prefixed_key, value in cache_results.items():
results[prefixed_keys_map[prefixed_key]] = value
missing_prefixed_keys = [k for k in prefixed_keys_map if prefixed_keys_map[k] not in results]
if missing_prefixed_keys:
try:
async for const in self._model._default_manager.filter(key__in=missing_prefixed_keys):
results[prefixed_keys_map[const.key]] = loads(const.value)
except (OperationalError, ProgrammingError):
pass
return results
def set(self, key, value):
key = self.add_prefix(key)
created = False
queryset = self._model._default_manager.all()
# Set _for_write attribute as get_or_create method does
# https://github.com/django/django/blob/2.2.11/django/db/models/query.py#L536
queryset._for_write = True
try:
constance = queryset.get(key=key)
except (OperationalError, ProgrammingError):
# database is not created, noop
return
except self._model.DoesNotExist:
try:
with transaction.atomic(using=queryset.db):
queryset.create(key=key, value=dumps(value))
created = True
except IntegrityError:
# Allow concurrent writes
constance = queryset.get(key=key)
if not created:
old_value = loads(constance.value)
constance.value = dumps(value)
constance.save(update_fields=["value"])
else:
old_value = None
if self._cache:
self._cache.set(key, value)
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def aset(self, key, value):
from asgiref.sync import sync_to_async
# We use sync_to_async because Django's transaction.atomic() and database connections are thread-local.
# This ensures the operation runs in the correct database thread until native async transactions are supported.
return await sync_to_async(self.set, thread_sensitive=True)(key, value)
def clear(self, sender, instance, created, **kwargs):
if self._cache and not created:
keys = [self.add_prefix(k) for k in settings.CONFIG]
keys.append(self.add_prefix(self._autofill_cachekey))
self._cache.delete_many(keys)
self.autofill()