Postgres connection pool, clean 503 under overload, time indexes, 4 workers
Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
53
config/db.py
Normal file
53
config/db.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
آزاد کردنِ اتصالِ دیتابیس پیش از یک انتظارِ طولانیِ شبکه.
|
||||
|
||||
با pool (config/settings.py) هر نخ اتصال را از اولین کوئری تا پایانِ درخواست نگه میدارد. جوابِ
|
||||
دستیار ۳ تا ۴۰ ثانیه منتظرِ Qdrant و مدل میماند؛ اگر اتصال در این مدت دستِ همان نخ بماند، چند
|
||||
سوالِ همزمان از دستیار کلِ pool را میگیرد و بقیهی اپ (نقشه، آزمون) پشتشان صف میکشد. این تابع
|
||||
اتصال را به pool پس میدهد؛ کوئریِ بعدی همان نخ خودکار یکی تازه قرض میگیرد.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db import connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def release_db_connection() -> None:
|
||||
"""
|
||||
اتصالِ این نخ را پس میدهد، مگر وسطِ یک تراکنش باشیم: بستنِ اتصال آنجا تراکنش را میکشد.
|
||||
در درخواستِ وب هیچ تراکنشی دورِ view نیست (ATOMIC_REQUESTS خاموش است)؛ تراکنشهایی که
|
||||
TestCase دورِ هر تست میپیچد بیصدا رد میشوند، و هر atomic()ِ دیگری یعنی کسی این را داخلِ
|
||||
تراکنشِ خودش صدا زده (باگ؛ اتصال نگه داشته و WARNING لاگ میشود).
|
||||
"""
|
||||
if connection.in_atomic_block:
|
||||
if not all(getattr(a, '_from_testcase', False) for a in connection.atomic_blocks):
|
||||
logger.warning('release_db_connection called inside atomic(); connection kept')
|
||||
return
|
||||
connection.close()
|
||||
|
||||
|
||||
# ── pool پر: ۵۰۳ بهجای ۵۰۰ ──
|
||||
# وقتی همهی اتصالهای pool تا DB_POOL_TIMEOUT ثانیه مشغول ماندند، درخواست ۵۰۳ با Retry-After میگیرد
|
||||
# (کاهشِ بار، مثلِ سرویسهای بزرگ زیرِ ازدحام): کلاینت میفهمد خرابی نیست و چند ثانیه بعد دوباره
|
||||
# میفرستد. هر بار ERROR لاگ میشود، چون یعنی بار از ظرفیتِ این سرور گذشته. دو جا صدا زده میشود:
|
||||
# هندلرِ خطای DRF (config/exceptions.py؛ هر خطای ناشناخته را خودش ۵۰۰ میکند و به میانافزار نمیرسد)
|
||||
# و DatabaseBusyMiddleware برای viewهای غیرِ DRF مثلِ /admin/.
|
||||
SERVER_BUSY_RETRY_AFTER = 5
|
||||
SERVER_BUSY_BODY = {'detail': 'سرور الان خیلی شلوغ است؛ چند ثانیهی دیگر دوباره امتحان کنید.', 'code': 'server_busy'}
|
||||
|
||||
|
||||
def is_pool_timeout(exc) -> bool:
|
||||
"""خطای «همهی اتصالهای pool مشغولاند». جنگو PoolTimeout را در OperationalError میپیچد."""
|
||||
from django.db import OperationalError
|
||||
|
||||
if not isinstance(exc, OperationalError):
|
||||
return False
|
||||
from psycopg_pool import PoolTimeout
|
||||
|
||||
return isinstance(exc.__cause__, PoolTimeout)
|
||||
|
||||
|
||||
def log_server_busy(request) -> None:
|
||||
logger.error('db_pool_exhausted method=%s path=%s',
|
||||
getattr(request, 'method', ''), getattr(request, 'path', ''))
|
||||
@@ -8,6 +8,8 @@ JSON. این هندلر اون حالت رو با traceback کامل لاگ می
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
|
||||
|
||||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
@@ -25,6 +27,11 @@ def custom_exception_handler(exc, context):
|
||||
# بدنهی بزرگتر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ میکند، ولی
|
||||
# چون DRF آن را نمیشناسد پیشتر اینجا «۵۰۰ + ERRORِ لاگ با traceback» میشد و هر درخواستِ بزرگ لاگ را پر
|
||||
# میکرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را میگیرند.
|
||||
# poolِ دیتابیس پر (config/db.py): ۵۰۳ با Retry-After. پیش از خواندنِ request.user پایینتر، که اگر
|
||||
# احرازِ هویت خودش همین خطا را داده بود دوباره منتظرِ pool میماند.
|
||||
if is_pool_timeout(exc):
|
||||
log_server_busy(request)
|
||||
return Response(SERVER_BUSY_BODY, status=503, headers={'Retry-After': str(SERVER_BUSY_RETRY_AFTER)})
|
||||
if isinstance(exc, RequestDataTooBig):
|
||||
logger.warning('request_too_large path=%s', path)
|
||||
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
|
||||
|
||||
@@ -12,6 +12,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
||||
|
||||
عمداً هیچ سکرتی برنمیگرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
|
||||
"""
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
@@ -187,6 +188,67 @@ def _slot_state(row) -> dict:
|
||||
return {'state': state, 'lagBytes': lag}
|
||||
|
||||
|
||||
# اتصالهایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
|
||||
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
|
||||
# عوض میکند و تا بسته شدنِ قبلی همزمان بالاست (یک pool).
|
||||
DB_CONNECTION_RESERVE = 6
|
||||
|
||||
|
||||
def _db_pool() -> dict:
|
||||
"""
|
||||
poolِ اتصالِ همین worker (config/settings.py) و بودجهی کلِ اتصالها در برابرِ max_connections.
|
||||
|
||||
آمارِ pool مالِ workerی است که این درخواست را جواب داد، نه همه؛ `requestsErrors` یعنی چند بار
|
||||
درخواستی تا DB_POOL_TIMEOUT اتصال نگرفت و ۵۰۳ شد (از بالا آمدنِ این worker). بودجه از
|
||||
GUNICORN_WORKERS (gunicorn.conf.py، post_fork) حساب میشود؛ بیرون از gunicorn (runserver) ۱ است.
|
||||
"""
|
||||
if connection.vendor != 'postgresql':
|
||||
return {'state': 'not_postgres'}
|
||||
pool = connection.pool
|
||||
if pool is None:
|
||||
return {'state': 'off'}
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT current_setting('max_connections')::int - current_setting('superuser_reserved_connections')::int, "
|
||||
'(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database())'
|
||||
)
|
||||
allowed, in_use = cursor.fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {'state': 'error', 'error': type(exc).__name__}
|
||||
stats = pool.get_stats()
|
||||
workers = int(os.environ.get('GUNICORN_WORKERS', '1'))
|
||||
budget = workers * pool.max_size + DB_CONNECTION_RESERVE
|
||||
return {
|
||||
'state': 'ok' if budget <= allowed else 'over_budget',
|
||||
'workers': workers,
|
||||
'poolMaxSize': pool.max_size,
|
||||
'budget': budget,
|
||||
'maxConnections': allowed,
|
||||
'connectionsInUse': in_use,
|
||||
'thisWorker': {
|
||||
'size': stats.get('pool_size', 0),
|
||||
'available': stats.get('pool_available', 0),
|
||||
'waiting': stats.get('requests_waiting', 0),
|
||||
'requests': stats.get('requests_num', 0),
|
||||
'requestsQueued': stats.get('requests_queued', 0),
|
||||
'requestsErrors': stats.get('requests_errors', 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _db_pool_warnings(pool: dict) -> list[str]:
|
||||
if pool.get('state') == 'over_budget':
|
||||
return [
|
||||
f"بودجهی اتصالِ دیتابیس ({pool['workers']} worker × {pool['poolMaxSize']} + {DB_CONNECTION_RESERVE} رزرو "
|
||||
f"= {pool['budget']}) از سقفِ Postgres ({pool['maxConnections']}) بیشتر است: زیرِ بار درخواستها ۵۰۰ میگیرند. "
|
||||
'DB_POOL_MAX_SIZE یا تعدادِ workerها را کم کنید.'
|
||||
]
|
||||
if pool.get('state') == 'error':
|
||||
return ['وضعیتِ poolِ دیتابیس خوانده نشد.']
|
||||
return []
|
||||
|
||||
|
||||
_OFFSITE_MESSAGES = {
|
||||
'not_configured': 'کپیِ زندهی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).',
|
||||
'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچوقت وصل نشده یا اسلات حذف شده.',
|
||||
@@ -282,7 +344,8 @@ class HealthView(APIView):
|
||||
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||||
|
||||
offsite = _offsite_backup()
|
||||
warnings = _warnings() + _offsite_warnings(offsite)
|
||||
db_pool = _db_pool()
|
||||
warnings = _warnings() + _offsite_warnings(offsite) + _db_pool_warnings(db_pool)
|
||||
payload = {
|
||||
'status': 'ok' if critical_ok else 'error',
|
||||
'debug': bool(settings.DEBUG),
|
||||
@@ -291,6 +354,7 @@ class HealthView(APIView):
|
||||
'integrations': _check_integrations(),
|
||||
'traffic': _traffic(),
|
||||
'offsiteBackup': offsite,
|
||||
'dbPool': db_pool,
|
||||
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
||||
# IPای که throttle برای همین درخواست میبیند. باید IPِ عمومیِ خودِ
|
||||
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
|
||||
|
||||
@@ -112,6 +112,33 @@ class APINoStoreMiddleware:
|
||||
return response
|
||||
|
||||
|
||||
class DatabaseBusyMiddleware:
|
||||
"""
|
||||
poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛
|
||||
viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب میدهد، چون هر خطا را خودش ۵۰۰ میکند
|
||||
و استثنا هیچوقت به اینجا نمیرسد. فقط خطای pool را میگیرد؛ هر OperationalErrorِ دیگری (Postgres
|
||||
پایین، کوئریِ timeoutخورده) همان ۵۰۰ِ قبلی میماند.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
return self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
from django.http import JsonResponse
|
||||
|
||||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||
|
||||
if not is_pool_timeout(exception):
|
||||
return None
|
||||
log_server_busy(request)
|
||||
response = JsonResponse(SERVER_BUSY_BODY, status=503)
|
||||
response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER)
|
||||
return response
|
||||
|
||||
|
||||
class AdminLoginThrottleMiddleware:
|
||||
"""
|
||||
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
|
||||
|
||||
@@ -282,6 +282,8 @@ MIDDLEWARE = [
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
# پاسخِ API هرگز در کشِ ArvanCloud/مرورگر نماند (config/middleware.py).
|
||||
'config.middleware.APINoStoreMiddleware',
|
||||
# poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ برای viewهای غیرِ DRF (config/db.py).
|
||||
'config.middleware.DatabaseBusyMiddleware',
|
||||
# ⚠️ افزوده شده: لاگِ سبکِ هر درخواستِ API (متد/مسیر/وضعیت/مدت/کاربر)؛
|
||||
# عمداً آخرِ لیست قرار گرفته تا نزدیکترین میانافزار به خودِ view باشه
|
||||
# (مدتزمانِ دقیقتر، و request.user که توسطِ DRF authentication در
|
||||
@@ -322,6 +324,33 @@ if DB_NAME:
|
||||
_missing = [k for k in ('DB_USER', 'DB_PASSWORD') if not os.environ.get(k)]
|
||||
if _missing:
|
||||
raise RuntimeError('DB_NAME ست شده ولی %s خالی است.' % ', '.join(_missing))
|
||||
if 'DB_CONN_MAX_AGE' in os.environ:
|
||||
# pool جای اتصالِ دائمی را گرفته و جنگو این دو را با هم نمیپذیرد؛ مقدارِ قدیمی بیصدا
|
||||
# نادیده گرفته نمیشود تا کسی خیال نکند هنوز اثر دارد.
|
||||
raise RuntimeError('DB_CONN_MAX_AGE دیگر معنی ندارد (اتصالها از pool میآیند)؛ از .env برش دارید.')
|
||||
|
||||
# ── pool اتصالها ──
|
||||
# تا ۲۰۲۶-۱۰-۱۱ هر نخِ gunicorn (۳ worker × ۳۲ نخ = ۹۶) با CONN_MAX_AGE یک اتصالِ دائمیِ جدا
|
||||
# نگه میداشت، ولی Postgresِ ما max_connections=60 دارد (۵۷ تا برای غیرِ ابرکاربر). روی
|
||||
# Postgresِ محلی با همان سقف، ۱۵۰ درخواستِ همزمان روی /api/home یکسومِ درخواستها را ۵۰۰
|
||||
# کرد («remaining connection slots are reserved»). حالا هر worker یک pool دارد و نخها اتصال
|
||||
# را فقط تا پایانِ درخواست قرض میگیرند؛ زیرِ ازدحام نخها در صفِ pool منتظر میمانند و پس از
|
||||
# DB_POOL_TIMEOUT ثانیه ۵۰۳ با Retry-After میگیرند (config/db.py)،
|
||||
# نه ۵۰۰. بودجه: workers × DB_POOL_MAX_SIZE باید زیرِ max_connections بماند و جا برای
|
||||
# pg_receivewal، pg_dump و migrateِ دیپلو، و psqlِ دستی بگذارد؛ /api/health آن را میسنجد.
|
||||
# ۸ عدد: هر کوئری اینجا چند میلیثانیه است، پس ۸ اتصال صدها درخواست در ثانیه را برای یک
|
||||
# worker (که با GIL حداکثر یک هسته است) میکشد؛ در لودتستِ محلی از ۸ به ۱۶ گذرشی بیشتر نشد.
|
||||
DB_POOL_MIN_SIZE = int(os.environ.get('DB_POOL_MIN_SIZE', '2'))
|
||||
DB_POOL_MAX_SIZE = int(os.environ.get('DB_POOL_MAX_SIZE', '8'))
|
||||
DB_POOL_TIMEOUT = float(os.environ.get('DB_POOL_TIMEOUT', '10'))
|
||||
if not 1 <= DB_POOL_MIN_SIZE <= DB_POOL_MAX_SIZE:
|
||||
# psycopg_pool این را تازه سرِ اولین کوئری میفهمد و آنوقت همهی درخواستها ۵۰۰ میشوند.
|
||||
raise RuntimeError(f'DB_POOL_MIN_SIZE ({DB_POOL_MIN_SIZE}) باید بینِ ۱ و DB_POOL_MAX_SIZE ({DB_POOL_MAX_SIZE}) باشد.')
|
||||
# کوئریِ بیش از ۳۰ ثانیه در درخواستِ وب یعنی باگ یا قفلِ گیرکرده؛ بدونِ سقف اتصالِ pool و نخ را
|
||||
# تا timeoutِ gunicorn (۱۲۰ ثانیه) گرو میگرفت. تراکنشِ بازِ بیکار (کدی که atomic را رها نکرده)
|
||||
# بعد از ۶۰ ثانیه بسته میشود تا قفلهایش بقیه را نخواباند. برای یک فرمانِ مدیریتیِ طولانیِ دستی:
|
||||
# DB_STATEMENT_TIMEOUT_MS=0 python manage.py ...
|
||||
DB_STATEMENT_TIMEOUT_MS = int(os.environ.get('DB_STATEMENT_TIMEOUT_MS', '30000'))
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
@@ -330,7 +359,22 @@ if DB_NAME:
|
||||
'PASSWORD': os.environ['DB_PASSWORD'],
|
||||
'HOST': os.environ.get('DB_HOST', '127.0.0.1'),
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||||
'CONN_MAX_AGE': 0,
|
||||
# اتصالِ مرده (مثلاً بعد از ریاستارتِ Postgres) پیش از تحویل از pool کنار گذاشته میشود،
|
||||
# نه اینکه اولین درخواستِ بعدی رویش ۵۰۰ بگیرد. هزینه: یک رفتوبرگشتِ محلی برای هر قرض.
|
||||
'CONN_HEALTH_CHECKS': True,
|
||||
'OPTIONS': {
|
||||
'pool': {
|
||||
'min_size': DB_POOL_MIN_SIZE,
|
||||
'max_size': DB_POOL_MAX_SIZE,
|
||||
'timeout': DB_POOL_TIMEOUT,
|
||||
'max_idle': 300,
|
||||
'max_lifetime': 1800,
|
||||
},
|
||||
'connect_timeout': 5,
|
||||
'options': (f'-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} '
|
||||
'-c idle_in_transaction_session_timeout=60000'),
|
||||
},
|
||||
# قفلِ ردیف روی Postgres واقعی است (select_for_update)؛ transaction_mode
|
||||
# فقط برای SQLite بود. با تستهای همزمانی روی Postgres سنجیده میشود.
|
||||
}
|
||||
|
||||
192
config/tests_db_pool.py
Normal file
192
config/tests_db_pool.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
poolِ اتصالِ Postgres و رفتارِ زیرِ ازدحام (config/settings.py، config/db.py).
|
||||
|
||||
بارِ واقعی با لودتستِ محلی سنجیده شد (Postgresِ ۱۴ با max_connections=60 مثلِ سرور، ۳ worker × ۳۲ نخ،
|
||||
۱۰۰ کاربرِ همزمان): کدِ قبلی ۲۱٪ِ درخواستها را ۵۰۰ کرد و هر ۵۷ اتصال را گرفت؛ با pool صفر خطا و ۲۴ اتصال.
|
||||
این تستها تکههایی را نگه میدارند که بیلودتست هم میشود سنجید.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import OperationalError, connection, transaction
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase, TransactionTestCase
|
||||
from psycopg_pool import PoolTimeout
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from config import health
|
||||
from config.db import is_pool_timeout, release_db_connection
|
||||
from config.middleware import DatabaseBusyMiddleware
|
||||
from content.models import Grade
|
||||
|
||||
|
||||
def _pool_timeout_error():
|
||||
"""همان شکلی که جنگو PoolTimeout را بالا میدهد (wrap_database_errors)."""
|
||||
err = OperationalError("couldn't get a connection after 10.00 sec")
|
||||
err.__cause__ = PoolTimeout("couldn't get a connection after 10.00 sec")
|
||||
return err
|
||||
|
||||
|
||||
class PoolTimeoutDetectionTests(SimpleTestCase):
|
||||
def test_only_a_wrapped_pool_timeout_counts(self):
|
||||
self.assertTrue(is_pool_timeout(_pool_timeout_error()))
|
||||
self.assertFalse(is_pool_timeout(OperationalError('server closed the connection')))
|
||||
self.assertFalse(is_pool_timeout(PoolTimeout('raw, not wrapped by django')))
|
||||
self.assertFalse(is_pool_timeout(ValueError()))
|
||||
|
||||
|
||||
class ServerBusyResponseTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007711')
|
||||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||
|
||||
def test_drf_view_answers_503_with_retry_after_not_500(self):
|
||||
# هندلرِ خطای DRF (config/exceptions.py) هر خطای ناشناخته را خودش ۵۰۰ میکند و به میانافزار
|
||||
# نمیرسد؛ اولین نسخهی این تغییر فقط میانافزار را داشت و در لودتست همه ۵۰۰ ماند.
|
||||
with mock.patch('content.views.HomeView.get', side_effect=_pool_timeout_error()), \
|
||||
self.assertLogs('config.db', level='ERROR') as logs:
|
||||
response = self.client.get('/api/home', **self.auth)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response['Retry-After'], '5')
|
||||
self.assertEqual(response.json()['code'], 'server_busy')
|
||||
self.assertIn('db_pool_exhausted', logs.output[0])
|
||||
|
||||
def test_other_database_errors_stay_500(self):
|
||||
with mock.patch('content.views.HomeView.get', side_effect=OperationalError('db down')), \
|
||||
self.assertLogs('config.exceptions', level='ERROR'):
|
||||
response = self.client.get('/api/home', **self.auth)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
def test_middleware_covers_non_drf_views(self):
|
||||
request = RequestFactory().get('/admin/')
|
||||
middleware = DatabaseBusyMiddleware(lambda r: None)
|
||||
with self.assertLogs('config.db', level='ERROR'):
|
||||
response = middleware.process_exception(request, _pool_timeout_error())
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response['Retry-After'], '5')
|
||||
self.assertIsNone(middleware.process_exception(request, OperationalError('db down')))
|
||||
|
||||
|
||||
class ReleaseConnectionTests(TransactionTestCase):
|
||||
def test_outside_a_transaction_the_connection_goes_back(self):
|
||||
User.objects.count()
|
||||
self.assertIsNotNone(connection.connection)
|
||||
release_db_connection()
|
||||
self.assertIsNone(connection.connection)
|
||||
self.assertEqual(User.objects.count(), 0) # کوئریِ بعدی خودش یکی تازه میگیرد
|
||||
|
||||
def test_inside_atomic_it_keeps_the_connection_and_warns(self):
|
||||
with transaction.atomic():
|
||||
User.objects.count()
|
||||
with self.assertLogs('config.db', level='WARNING'):
|
||||
release_db_connection()
|
||||
self.assertIsNotNone(connection.connection)
|
||||
User.objects.create(phone='09120007712') # تراکنش سالم ماند
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
|
||||
class ReleaseUnderTestCaseIsSilentTests(TestCase):
|
||||
def test_testcase_transaction_is_not_reported_as_a_bug(self):
|
||||
logger = logging.getLogger('config.db')
|
||||
with mock.patch.object(logger, 'warning') as warning:
|
||||
release_db_connection()
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
class AskReleasesTheConnectionBeforeTheModelTests(TransactionTestCase):
|
||||
"""دستیار تا ۴۰ ثانیه منتظرِ Qdrant و مدل است؛ در این مدت نباید اتصالی از pool دستش باشد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
self.user = User.objects.create(phone='09120007713', grade_id=grade.id)
|
||||
self.seen = {}
|
||||
|
||||
def _ask(self, **extra):
|
||||
return self.client.post(
|
||||
'/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra}, content_type='application/json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
|
||||
)
|
||||
|
||||
def _retrieve(self, *args, **kwargs):
|
||||
self.seen['retrieve'] = connection.connection
|
||||
return [{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'}
|
||||
|
||||
def test_json_path(self):
|
||||
def generate(*args, **kwargs):
|
||||
self.seen['generate'] = connection.connection
|
||||
return 'جواب', [], {}
|
||||
|
||||
with mock.patch('rag.services.config.rag_is_configured', return_value=True), \
|
||||
mock.patch('rag.services.retrieval.retrieve_context', side_effect=self._retrieve), \
|
||||
mock.patch('rag.services.generation.generate_answer', side_effect=generate):
|
||||
response = self._ask()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(self.seen['retrieve'])
|
||||
self.assertIsNone(self.seen['generate'])
|
||||
|
||||
def test_stream_path(self):
|
||||
def stream(*args, **kwargs):
|
||||
self.seen['stream'] = connection.connection
|
||||
yield 'جواب'
|
||||
|
||||
with mock.patch('rag.services.config.rag_is_configured', return_value=True), \
|
||||
mock.patch('rag.services.retrieval.retrieve_context', side_effect=self._retrieve), \
|
||||
mock.patch('rag.services.generation.stream_answer', side_effect=stream):
|
||||
response = self._ask(stream=True)
|
||||
body = b''.join(response.streaming_content).decode()
|
||||
self.assertIn('event: done', body)
|
||||
self.assertIsNone(self.seen['retrieve'])
|
||||
self.assertIsNone(self.seen['stream'])
|
||||
|
||||
|
||||
class PoolBudgetHealthTests(SimpleTestCase):
|
||||
def test_over_budget_is_a_warning(self):
|
||||
warnings = health._db_pool_warnings(
|
||||
{'state': 'over_budget', 'workers': 6, 'poolMaxSize': 10, 'budget': 66, 'maxConnections': 57})
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn('66', warnings[0])
|
||||
self.assertEqual(health._db_pool_warnings({'state': 'ok'}), [])
|
||||
self.assertEqual(health._db_pool_warnings({'state': 'not_postgres'}), [])
|
||||
|
||||
|
||||
class PoolBudgetOnPostgresTests(TestCase):
|
||||
def test_reports_the_real_limits(self):
|
||||
if connection.vendor != 'postgresql':
|
||||
self.skipTest('pool فقط روی Postgres است')
|
||||
with mock.patch.dict(os.environ, {'GUNICORN_WORKERS': '4'}):
|
||||
state = health._db_pool()
|
||||
self.assertEqual(state['workers'], 4)
|
||||
self.assertEqual(state['budget'], 4 * state['poolMaxSize'] + health.DB_CONNECTION_RESERVE)
|
||||
self.assertGreater(state['maxConnections'], 0)
|
||||
self.assertIn(state['state'], ('ok', 'over_budget'))
|
||||
|
||||
|
||||
class PoolSettingsFailLoudlyTests(SimpleTestCase):
|
||||
"""تنظیمِ غلط باید سرویس را بالا نیاورد، نه اینکه سرِ اولین کوئری همهی درخواستها ۵۰۰ شوند."""
|
||||
|
||||
def _load_settings(self, **env):
|
||||
base = {k: v for k, v in os.environ.items() if not k.startswith('DB_')}
|
||||
base.update({'DB_NAME': 'x', 'DB_USER': 'x', 'DB_PASSWORD': 'x', **env})
|
||||
return subprocess.run(
|
||||
[sys.executable, '-c', 'import config.settings'],
|
||||
env=base, capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(__file__)),
|
||||
)
|
||||
|
||||
def test_good_settings_load(self):
|
||||
self.assertEqual(self._load_settings().returncode, 0)
|
||||
|
||||
def test_old_conn_max_age_is_refused(self):
|
||||
result = self._load_settings(DB_CONN_MAX_AGE='60')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('DB_CONN_MAX_AGE', result.stderr)
|
||||
|
||||
def test_min_above_max_is_refused(self):
|
||||
result = self._load_settings(DB_POOL_MIN_SIZE='3', DB_POOL_MAX_SIZE='2')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('DB_POOL_MIN_SIZE', result.stderr)
|
||||
@@ -37,4 +37,7 @@ class OffsiteWarningTests(SimpleTestCase):
|
||||
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
|
||||
|
||||
def test_sqlite_reports_not_postgres(self):
|
||||
# سوئیت روی Postgres هم اجرا میشود (پیش از هر تغییرِ دیتابیسی)؛ آنجا این حالت معنی ندارد.
|
||||
if health.connection.vendor != 'sqlite':
|
||||
self.skipTest('فقط روی SQLite')
|
||||
self.assertEqual(health._offsite_backup(), {'state': 'not_postgres'})
|
||||
|
||||
Reference in New Issue
Block a user