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', ''))