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:
18
accounts/migrations/0010_alter_otpcode_expires_at.py
Normal file
18
accounts/migrations/0010_alter_otpcode_expires_at.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.17 on 2026-10-11 16:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_loginevent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='otpcode',
|
||||
name='expires_at',
|
||||
field=models.DateTimeField(db_index=True),
|
||||
),
|
||||
]
|
||||
@@ -76,7 +76,9 @@ class Session(models.Model):
|
||||
class OTPCode(models.Model):
|
||||
phone = models.CharField(max_length=15, db_index=True)
|
||||
code = models.CharField(max_length=6)
|
||||
expires_at = models.DateTimeField()
|
||||
# ایندکس: سقفِ سراسریِ پیامکِ روزانه در هر درخواستِ کد کدهای ۲۴ ساعتِ اخیر را میشمارد
|
||||
# (accounts/views.py)؛ ردیفها پاک نمیشوند، پس بیایندکس این شمارش با هر ورود کندتر میشد.
|
||||
expires_at = models.DateTimeField(db_index=True)
|
||||
is_used = models.BooleanField(default=False)
|
||||
# تلاشهای غلط روی همین کد. در دیتابیس شمرده میشود، نه در کشِ LocMem که
|
||||
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل میشود.
|
||||
|
||||
@@ -15,6 +15,8 @@ from rest_framework.response import Response
|
||||
from rest_framework.throttling import BaseThrottle, ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.db import release_db_connection
|
||||
|
||||
from .authentication import invalidate_user_cache, issue_access_token
|
||||
from .models import LoginEvent, OTPCode, Profile, Session, User, hash_refresh_token
|
||||
from .services import OTPDeliveryError, send_otp_sms
|
||||
@@ -195,6 +197,9 @@ class RequestOTPView(APIView):
|
||||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
|
||||
# کاوهنگار تا چند ثانیه طول میکشد؛ روزِ لانچ این پرترافیکترین مسیر است و اتصالِ دیتابیس
|
||||
# در این مدت به pool برمیگردد (config/db.py).
|
||||
release_db_connection()
|
||||
try:
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
except OTPDeliveryError:
|
||||
|
||||
Reference in New Issue
Block a user