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

@@ -184,7 +184,9 @@ curl https://mathapi.tadrisino2.ir/api/health
| `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد. روی سرور به Redisِ اختصاصیِ خودمان (`zareian-redis`، پورت ۶۳۹۰) اشاره می‌کند، نه Redisِ مشترکِ ماشین. با آن، قطعیِ Redis API را ۵۰۰ نمی‌کند ولی تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند و health ۵۰۳ می‌دهد (`config/cache.py`) |
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی |
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: ۴ workerِ `gthread` با ۳۲ نخ و timeoutِ ۱۲۰. تعدادِ workerها همین‌جاست؛ یونیتِ systemd دیگر `--workers` ندارد (اگر داشته باشد بر این فایل مقدم است) |
| `DB_POOL_MAX_SIZE` · `DB_POOL_MIN_SIZE` · `DB_POOL_TIMEOUT` | ۸ · ۲ · ۱۰ ثانیه: هر worker حداکثر ۸ اتصالِ Postgres دارد و نخ‌ها آن را فقط تا پایانِ درخواست قرض می‌گیرند. اگر تا ۱۰ ثانیه اتصالی آزاد نشد، درخواست ۵۰۳ با `Retry-After` می‌گیرد (نه ۵۰۰). `workers × DB_POOL_MAX_SIZE + 6` باید زیرِ `max_connections` (روی سرور ۶۰ − ۳) بماند؛ `/api/health` → `dbPool` آن را می‌سنجد و هشدار می‌دهد. `DB_CONN_MAX_AGE` دیگر پذیرفته نمی‌شود (برنامه بالا نمی‌آید) |
| `DB_STATEMENT_TIMEOUT_MS` | ۳۰٬۰۰۰: هر کوئریِ بیش از ۳۰ ثانیه لغو می‌شود و تراکنشِ بازِ بیکار بعد از ۶۰ ثانیه بسته می‌شود. برای فرمانِ مدیریتیِ طولانیِ دستی: `DB_STATEMENT_TIMEOUT_MS=0 python manage.py …` |
| `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `FRONTEND_URL/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، به‌علاوه‌ی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر می‌داند (fail-closed)؛ `/api/health` → `bazaar: false` |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |