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:
@@ -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 غلط
|
||||
|
||||
Reference in New Issue
Block a user