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', ''))
|
||||
Reference in New Issue
Block a user