optional short-TTL cache for the auth user row

TadrisinoTokenAuthentication ran one SELECT on accounts_user for every
authenticated request. Off by default (AUTH_USER_CACHE_TTL=0) so behaviour
is unchanged until a deployment opts in.

accounts/authentication.py
  - _get_request_user(): when AUTH_USER_CACHE_TTL>0, cache all concrete
    field values under auth:user:<id> and rebuild a full User instance
    (so save(update_fields=[...]) stays safe); otherwise load from DB as
    before. invalidate_user_cache() / _load_user_or_fail() helpers.
accounts/receivers.py (new)
  - post_save/post_delete on User -> invalidate_user_cache(pk); covers
    every in-app write path (SetUserGrade, login, refresh, last_activity).
accounts/apps.py
  - AccountsConfig.ready() imports receivers.
accounts/tests.py
  - AuthUserRowCacheTests: disabled -> loads every request; enabled ->
    loads once then cache; User.save() invalidates; SetUserGradeView still
    persists and the next request sees the new grade.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 08:46:11 +03:30
parent ff57adbc5f
commit d25ecab0b3
4 changed files with 130 additions and 7 deletions

View File

@@ -8,3 +8,4 @@ class AccountsConfig(AppConfig):
def ready(self):
# Registers OpenApiAuthenticationExtension for the Swagger UI Authorize button.
from . import schema # noqa: F401
from . import receivers # noqa: F401 (باطل‌کردنِ کشِ ردیفِ کاربر)

View File

@@ -1,5 +1,6 @@
from django.conf import settings
from django.core import signing
from django.core.cache import cache
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
@@ -7,6 +8,48 @@ from .models import User
ACCESS_TOKEN_SALT = 'tadrisino-access-token'
# فیلدهای غیرِ‌رابطه‌ایِ User که کش می‌شن؛ همه‌ی concrete fieldها تا بازسازیِ
# instance برای save(update_fields=...) امن بمونه.
_USER_CACHE_FIELDS = [f.attname for f in User._meta.concrete_fields]
def _auth_user_cache_key(user_id: int) -> str:
return f'auth:user:{user_id}'
def invalidate_user_cache(user_id: int) -> None:
cache.delete(_auth_user_cache_key(user_id))
def _load_user_or_fail(user_id: int) -> User:
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
def _get_request_user(user_id: int) -> User:
"""
ردیفِ کاربر برای request جاری. با AUTH_USER_CACHE_TTL>0 از کش خونده
می‌شه (یک SELECT کمتر در هر request authenticated). instanceِ بازسازی‌شده
کاملِ همه‌ی فیلدهاست، پس save(update_fields=[...]) روش امنه.
"""
ttl = getattr(settings, 'AUTH_USER_CACHE_TTL', 0)
if not ttl:
return _load_user_or_fail(user_id)
key = _auth_user_cache_key(user_id)
data = cache.get(key)
if data is None:
user = _load_user_or_fail(user_id)
cache.set(key, {f: getattr(user, f) for f in _USER_CACHE_FIELDS}, ttl)
return user
user = User(**data)
user._state.adding = False
user._state.db = 'default'
return user
def issue_access_token(user_id: int) -> str:
"""
@@ -48,13 +91,7 @@ class TadrisinoTokenAuthentication(BaseAuthentication):
token = parts[1]
user_id = decode_access_token(token)
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
return (user, token)
return (_get_request_user(user_id), token)
def authenticate_header(self, request):
return self.keyword

22
accounts/receivers.py Normal file
View File

@@ -0,0 +1,22 @@
"""
باطل‌کردنِ کشِ ردیفِ کاربر (auth:user:<id>) با هر تغییرِ User.
این سیگنال‌ها همه‌ی مسیرهای نوشتنِ داخلِ اپ (SetUserGrade، لاگین، رفرش‌توکن،
last_activity_at، ...) را پوشش می‌دهند؛ فقط تغییری که کاملاً بیرون از ORM
اعمال شود (مثلاً UPDATE مستقیمِ SQL) تا سقفِ AUTH_USER_CACHE_TTL کهنه می‌ماند.
"""
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from .authentication import invalidate_user_cache
from .models import User
@receiver(post_save, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_save')
def _invalidate_on_save(sender, instance, **kwargs):
invalidate_user_cache(instance.pk)
@receiver(post_delete, sender=User, dispatch_uid='accounts_invalidate_user_cache_on_delete')
def _invalidate_on_delete(sender, instance, **kwargs):
invalidate_user_cache(instance.pk)

View File

@@ -1430,3 +1430,66 @@ class SupervisorGroupAccessTests(TestCase):
self.client.force_login(non_staff_supervisor)
response = self.client.get(reverse('admin:accounts_user_changelist'))
self.assertEqual(response.status_code, 302)
class AuthUserRowCacheTests(TestCase):
"""کشِ کوتاهِ ردیفِ کاربر در TadrisinoTokenAuthentication."""
def setUp(self):
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='dev-authcache', is_guest=True, name='n')
self.token = issue_access_token(self.user.id)
# هر endpointِ authenticated؛ /api/user/hearts سبک است.
self.url = '/api/user/hearts'
def _hit(self):
return self.client.get(self.url, HTTP_AUTHORIZATION=f'Bearer {self.token}')
@override_settings(AUTH_USER_CACHE_TTL=0)
def test_disabled_by_default_loads_every_request(self):
with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m:
self._hit(); self._hit()
self.assertEqual(m.call_count, 2)
@override_settings(AUTH_USER_CACHE_TTL=30)
def test_enabled_caches_after_first_load(self):
with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m:
self.assertEqual(self._hit().status_code, 200)
self.assertEqual(self._hit().status_code, 200)
self.assertEqual(m.call_count, 1)
@override_settings(AUTH_USER_CACHE_TTL=30)
def test_user_save_invalidates_cache(self):
self._hit() # cache populated
with patch('accounts.authentication._load_user_or_fail', wraps=_orig_loader()) as m:
self.user.name = 'changed'
self.user.save(update_fields=['name']) # post_save → invalidate
self._hit()
self.assertEqual(m.call_count, 1)
@override_settings(AUTH_USER_CACHE_TTL=30)
def test_set_grade_still_works_with_cache_on(self):
r = self.client.post(
'/api/user/grade', {'gradeId': 3}, format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(r.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.grade_id, 3)
# درخواستِ بعدی باید گریدِ تازه را ببیند (کش پس از save باطل شده).
prof = self.client.get(
'/api/user/profile', HTTP_AUTHORIZATION=f'Bearer {self.token}')
self.assertEqual(prof.data['gradeId'], 3)
def _orig_loader():
"""نسخه‌ی اصلیِ لودر برای wraps کردنِ Mock (بدونِ recursion)."""
from accounts.models import User as _U
from rest_framework.exceptions import AuthenticationFailed
def _load(user_id):
try:
return _U.objects.get(pk=user_id)
except _U.DoesNotExist:
raise AuthenticationFailed('کاربر پیدا نشد.')
return _load