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>
130 lines
5.6 KiB
Python
130 lines
5.6 KiB
Python
"""
|
||
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)
|