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

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