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

@@ -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 سنجیده می‌شود.
}