Files
MathApp/config/cache.py
Zareian d584d62904 Do not connect to the shared Redis: CACHE_URL stays empty, own Redis is an open decision
The owner decided (2026-10-07) that we do not connect to anything other teams run on the box, Redis
included. CACHE_URL was emptied on the server and our keys were removed from the shared Redis.

- settings: drop KEY_PREFIX / CACHE_KEY_PREFIX (it only existed because the Redis was shared); comments
  say CACHE_URL is intentionally empty and ResilientRedisCache is ready for our own Redis.
- config/cache.py docstring and tests updated accordingly (11 tests, still run against a local redis-server).
- CLAUDE.md: new server rule 8 (touch only our own files and service), status block, historical line
  that said "db1 is free" now says not to connect. README, operations, handbook, security doc (item 9
  reopened as an open decision) say the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 14:41:39 +03:30

130 lines
5.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
RedisCache که با قطعی‌ی Redis درخواست‌ها را خراب نمی‌کند، ولی بی‌صدا هم نیست.
وضعیت: فقط وقتی فعال است که `CACHE_URL` ست باشد. روی سرور (۲۰۲۶-۱۰-۰۷) ست نیست و کش `LocMemCache`
است: Redisِ آن ماشین مالِ تیم‌های دیگر است و عمداً به آن وصل نمی‌شویم. این کلاس برای Redisِ اختصاصیِ
خودمان نوشته و آزموده شده است.
چرا: سقفِ نرخ، کشِ برنامه‌ی درسی، کشِ جوابِ دستیار و شمارنده‌های ورود از کش می‌خوانند.
`django.core.cache.backends.redis.RedisCache` با هر خطای شبکه استثنا می‌دهد و چون DRF برای هر
درخواستِ throttleدار کش را می‌خواند، قطعیِ Redis یعنی ۵۰۰ برای تقریباً همه‌ی API (هر هفت تستِ
`config/tests_cache_resilience.py` روی کشِ معمولی می‌شکند).
رفتار هنگامِ خطا (همه با `logger.error`، حداکثر هر ۳۰ ثانیه یک بار برای هر پروسه تا لاگ پر نشود؛
و `/api/health` در همان حال ۵۰۳ می‌دهد چون پروبِ کش شکست می‌خورد):
get → default (miss) set/delete → نادیده add → True («کلید نبود، ادامه بده»)
incr → ValueError («کلید نیست»؛ همان‌چیزی که کدِ فراخواننده از قبل می‌شناسد)
نتیجه‌ی عملی: در طولِ قطعی سقف‌های مبتنی بر کش (throttleهای DRF، پنجره‌ی ساعتیِ OTP، سقفِ ورودِ ادمین،
قفلِ یک‌دقیقه‌ایِ بازسنجیِ پرداخت) **اعمال نمی‌شوند** (fail-open)؛ کشِ درس‌ها هر بار از دیتابیس ساخته می‌شود.
آن‌چه در دیتابیس شمرده می‌شود (cooldown و سقفِ روزانه‌ی هر شماره، سقفِ کلِ پیامک، حدسِ هر کدِ OTP) سر جایش
می‌ماند. انتخابِ fail-open تصمیمِ ما بود: بسته‌شدنِ کلِ API به‌خاطر خرابیِ یک سرویسِ مشترک بدتر از چند
دقیقه بی‌سقف است. نیاز به بازنگری دارد اگر ترافیکِ مخرب دیده شود.
آزموده نشده: خطاهایی که از `redis.exceptions.RedisError` و `OSError` نیستند (مثلاً خطای serializer) عمداً
گرفته نمی‌شوند.
"""
import logging
import threading
import time
import redis
from django.core.cache.backends.base import DEFAULT_TIMEOUT
from django.core.cache.backends.redis import RedisCache
logger = logging.getLogger(__name__)
_ERRORS = (redis.exceptions.RedisError, OSError)
_LOG_EVERY_SECONDS = 30
_last_logged = 0.0
_lock = threading.Lock()
def _report(operation: str, exc: Exception) -> None:
global _last_logged
now = time.monotonic()
with _lock:
due = (now - _last_logged) >= _LOG_EVERY_SECONDS or _last_logged == 0.0
if due:
_last_logged = now
if due:
logger.error(
'cache: %s روی Redis شکست خورد (%s: %s)؛ سقف‌های مبتنی بر کش تا برگشتنِ Redis اعمال نمی‌شوند '
'و /api/health ۵۰۳ می‌دهد.', operation, type(exc).__name__, exc,
)
class ResilientRedisCache(RedisCache):
def get(self, key, default=None, version=None):
try:
return super().get(key, default, version)
except _ERRORS as exc:
_report('get', exc)
return default
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().set(key, value, timeout, version)
except _ERRORS as exc:
_report('set', exc)
def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().add(key, value, timeout, version)
except _ERRORS as exc:
_report('add', exc)
return True
def delete(self, key, version=None):
try:
return super().delete(key, version)
except _ERRORS as exc:
_report('delete', exc)
return False
def get_many(self, keys, version=None):
try:
return super().get_many(keys, version)
except _ERRORS as exc:
_report('get_many', exc)
return {}
def set_many(self, data, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().set_many(data, timeout, version)
except _ERRORS as exc:
_report('set_many', exc)
return list(data)
def delete_many(self, keys, version=None):
try:
return super().delete_many(keys, version)
except _ERRORS as exc:
_report('delete_many', exc)
def has_key(self, key, version=None):
try:
return super().has_key(key, version)
except _ERRORS as exc:
_report('has_key', exc)
return False
def incr(self, key, delta=1, version=None):
try:
return super().incr(key, delta, version)
except _ERRORS as exc:
_report('incr', exc)
raise ValueError(f"Key '{key}' not found") from exc
def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None):
try:
return super().touch(key, timeout, version)
except _ERRORS as exc:
_report('touch', exc)
return False
def clear(self):
try:
return super().clear()
except _ERRORS as exc:
_report('clear', exc)