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:
2026-10-11 20:21:35 +03:30
parent d32d98d3f6
commit feaa5eb540
24 changed files with 585 additions and 35 deletions

53
config/db.py Normal file
View 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', ''))

View File

@@ -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)

View File

@@ -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 غلط

View File

@@ -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 در ۱۵ دقیقه).

View File

@@ -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
View 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)

View File

@@ -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'})