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

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-10-11 16:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0012_choice_value_300'),
]
operations = [
migrations.AlterField(
model_name='userlessoncompletion',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
migrations.AlterField(
model_name='usernodecompletion',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View File

@@ -148,7 +148,9 @@ class UserLessonCompletion(models.Model):
# سخت روی (user_id, lesson) درست نیست. idempotency_key فقط جلوی رکورد
# تکراریِ ناشی از دابل‌کلیک/رتری روی همان submit را می‌گیرد.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
# ایندکس: لیدربوردِ هفتگی (progress/leaderboard.py) ردیف‌های ۷ روزِ اخیرِ همه‌ی کاربرها را جمع می‌زند؛
# بی‌ایندکس هر بار کلِ جدول اسکن می‌شد.
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
constraints = [
@@ -184,7 +186,9 @@ class UserNodeCompletion(models.Model):
# تکرارِ آزمون مجاز است (فقط بارِ اول جایزه دارد)؛ کلید فقط جلوی ردیفِ
# تکراریِ ناشی از دابل‌کلیک/رتریِ همان submit را می‌گیرد.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
# ایندکس: لیدربوردِ هفتگی (progress/leaderboard.py) ردیف‌های ۷ روزِ اخیرِ همه‌ی کاربرها را جمع می‌زند؛
# بی‌ایندکس هر بار کلِ جدول اسکن می‌شد.
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
constraints = [