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:
@@ -30,6 +30,12 @@ DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
# pool اتصالها (config/settings.py): هر worker حداکثر DB_POOL_MAX_SIZE اتصال؛ بعد از DB_POOL_TIMEOUT ثانیه انتظار ۵۰۳.
|
||||
# workers × DB_POOL_MAX_SIZE + 6 باید زیرِ max_connectionsِ Postgres بماند (/api/health → dbPool).
|
||||
# DB_POOL_MAX_SIZE=8
|
||||
# DB_POOL_TIMEOUT=10
|
||||
# سقفِ هر کوئری (میلیثانیه)؛ 0 برای فرمانِ مدیریتیِ طولانیِ دستی.
|
||||
# DB_STATEMENT_TIMEOUT_MS=30000
|
||||
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده میگیرد (ops/pgbackup/). روی سرور: zareian_247.
|
||||
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار میدهد. روی لپتاپ خالی.
|
||||
OFFSITE_WAL_SLOT=
|
||||
|
||||
@@ -125,7 +125,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||||
|
||||
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
|
||||
|
||||
**Postgres** is the server's DB since 2026-10-08 (own cluster `14/zareian`, port 5440, localhost only; DB `zareian_mathapp`, role `zareian_app`; selected by `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT` in `.env`; passwords in `~/mathapp-v2/.pg_password`, `.redis_password`). It is separate from the company's `main` cluster on 5432 — never touch that. Without `DB_NAME` the app falls back to SQLite (local dev, tests); both run the full suite green. Data was moved with `dumpdata`/`loaddata`; model-count comparison matched. Guards that relied on SQLite's `IMMEDIATE` now rely on real row locks; concurrency tests pass on Postgres, not load-tested. `deploy.sh` backs up with `pg_dump -Fc` before `migrate`; the off-site live copy is in «بکاپ: کپیِ زنده» below. Operator guide: `~/Desktop/گزارشات Tadrisino/گزارش-۲۹/راهنمای-Postgres-و-Redis.html`. The pre-move `db.sqlite3` and migration dumps were deleted from the server; no SQLite file is left there.
|
||||
**Postgres** is the server's DB since 2026-10-08 (own cluster `14/zareian`, port 5440, localhost only; DB `zareian_mathapp`, role `zareian_app`; selected by `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT` in `.env`; passwords in `~/mathapp-v2/.pg_password`, `.redis_password`). It is separate from the company's `main` cluster on 5432 — never touch that. Without `DB_NAME` the app falls back to SQLite (local dev, tests); both run the full suite green. Data was moved with `dumpdata`/`loaddata`; model-count comparison matched. Guards that relied on SQLite's `IMMEDIATE` now rely on real row locks; concurrency tests pass on Postgres. **Connections come from a per-worker pool** (`OPTIONS.pool`, `DB_POOL_MAX_SIZE` 8; `CONN_MAX_AGE` is gone and `DB_CONN_MAX_AGE` in `.env` stops startup): Postgres here has `max_connections=60`, and the old one-connection-per-thread setup (3 workers × 32 threads) turned 21% of requests into 500s at 100 concurrent users in a local load test against a 60-connection Postgres; with the pool 0 errors. A full pool gives 503 + `Retry-After` (`config/db.py`), not 500; `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`. The AI tutor and the OTP SMS call hand their connection back before the network wait (`release_db_connection`). Worker count lives in `gunicorn.conf.py` (4; the systemd unit no longer passes `--workers`). Not load-tested on the server itself. `deploy.sh` backs up with `pg_dump -Fc` before `migrate`; the off-site live copy is in «بکاپ: کپیِ زنده» below. Operator guide: `~/Desktop/گزارشات Tadrisino/گزارش-۲۹/راهنمای-Postgres-و-Redis.html`. The pre-move `db.sqlite3` and migration dumps were deleted from the server; no SQLite file is left there.
|
||||
|
||||
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||||
|
||||
|
||||
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:
|
||||
|
||||
18
ai_tutor/migrations/0008_alter_message_created_at.py
Normal file
18
ai_tutor/migrations/0008_alter_message_created_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 = [
|
||||
('ai_tutor', '0007_message_charge'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='message',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, db_index=True),
|
||||
),
|
||||
]
|
||||
@@ -30,7 +30,9 @@ class Message(models.Model):
|
||||
# به API نرسید). سهمیهی رایگانِ امروز = تعدادِ «free»های امروز.
|
||||
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بیهزینه')]
|
||||
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
# ایندکس: سقفِ روزانهی کلِ سامانه (_check_global_daily_cap) در هر سوال پیامهای امروزِ همه را
|
||||
# میشمارد؛ این سریعترین جدولِ در حالِ رشد است.
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
|
||||
# --- حاشیهنویسیِ تولید (فقط برای پیامهای role='assistant') --------------
|
||||
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».
|
||||
|
||||
@@ -16,6 +16,7 @@ from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from accounts.models import User as AccountUser
|
||||
from config.db import release_db_connection
|
||||
from . import provider_status
|
||||
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
||||
from .serializers import (
|
||||
@@ -471,6 +472,8 @@ class AskAIView(APIView):
|
||||
answer_from_cache = True
|
||||
_uncharge(user_message)
|
||||
else:
|
||||
# Qdrant و مدل چند ثانیه طول میکشند؛ اتصالِ دیتابیس در این مدت به pool برمیگردد.
|
||||
release_db_connection()
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
_retrieval_query(v['userQuestion'], history, focus),
|
||||
subject=subject, grade_id=grade_id,
|
||||
@@ -610,6 +613,8 @@ class AskAIView(APIView):
|
||||
_uncharge(user_message)
|
||||
yield _sse('chunk', {'delta': answer_text})
|
||||
else:
|
||||
# کلِ استریم (تا ۴۰ ثانیه) بیاتصالِ دیتابیس؛ ثبتِ پیامِ آخر یکی تازه قرض میگیرد.
|
||||
release_db_connection()
|
||||
used_chunks, retrieval_meta = retrieve_context(
|
||||
_retrieval_query(v['userQuestion'], history, focus),
|
||||
subject=subject, grade_id=grade_id,
|
||||
|
||||
53
config/db.py
Normal file
53
config/db.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
آزاد کردنِ اتصالِ دیتابیس پیش از یک انتظارِ طولانیِ شبکه.
|
||||
|
||||
با pool (config/settings.py) هر نخ اتصال را از اولین کوئری تا پایانِ درخواست نگه میدارد. جوابِ
|
||||
دستیار ۳ تا ۴۰ ثانیه منتظرِ Qdrant و مدل میماند؛ اگر اتصال در این مدت دستِ همان نخ بماند، چند
|
||||
سوالِ همزمان از دستیار کلِ pool را میگیرد و بقیهی اپ (نقشه، آزمون) پشتشان صف میکشد. این تابع
|
||||
اتصال را به pool پس میدهد؛ کوئریِ بعدی همان نخ خودکار یکی تازه قرض میگیرد.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.db import connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def release_db_connection() -> None:
|
||||
"""
|
||||
اتصالِ این نخ را پس میدهد، مگر وسطِ یک تراکنش باشیم: بستنِ اتصال آنجا تراکنش را میکشد.
|
||||
در درخواستِ وب هیچ تراکنشی دورِ view نیست (ATOMIC_REQUESTS خاموش است)؛ تراکنشهایی که
|
||||
TestCase دورِ هر تست میپیچد بیصدا رد میشوند، و هر atomic()ِ دیگری یعنی کسی این را داخلِ
|
||||
تراکنشِ خودش صدا زده (باگ؛ اتصال نگه داشته و WARNING لاگ میشود).
|
||||
"""
|
||||
if connection.in_atomic_block:
|
||||
if not all(getattr(a, '_from_testcase', False) for a in connection.atomic_blocks):
|
||||
logger.warning('release_db_connection called inside atomic(); connection kept')
|
||||
return
|
||||
connection.close()
|
||||
|
||||
|
||||
# ── pool پر: ۵۰۳ بهجای ۵۰۰ ──
|
||||
# وقتی همهی اتصالهای pool تا DB_POOL_TIMEOUT ثانیه مشغول ماندند، درخواست ۵۰۳ با Retry-After میگیرد
|
||||
# (کاهشِ بار، مثلِ سرویسهای بزرگ زیرِ ازدحام): کلاینت میفهمد خرابی نیست و چند ثانیه بعد دوباره
|
||||
# میفرستد. هر بار ERROR لاگ میشود، چون یعنی بار از ظرفیتِ این سرور گذشته. دو جا صدا زده میشود:
|
||||
# هندلرِ خطای DRF (config/exceptions.py؛ هر خطای ناشناخته را خودش ۵۰۰ میکند و به میانافزار نمیرسد)
|
||||
# و DatabaseBusyMiddleware برای viewهای غیرِ DRF مثلِ /admin/.
|
||||
SERVER_BUSY_RETRY_AFTER = 5
|
||||
SERVER_BUSY_BODY = {'detail': 'سرور الان خیلی شلوغ است؛ چند ثانیهی دیگر دوباره امتحان کنید.', 'code': 'server_busy'}
|
||||
|
||||
|
||||
def is_pool_timeout(exc) -> bool:
|
||||
"""خطای «همهی اتصالهای pool مشغولاند». جنگو PoolTimeout را در OperationalError میپیچد."""
|
||||
from django.db import OperationalError
|
||||
|
||||
if not isinstance(exc, OperationalError):
|
||||
return False
|
||||
from psycopg_pool import PoolTimeout
|
||||
|
||||
return isinstance(exc.__cause__, PoolTimeout)
|
||||
|
||||
|
||||
def log_server_busy(request) -> None:
|
||||
logger.error('db_pool_exhausted method=%s path=%s',
|
||||
getattr(request, 'method', ''), getattr(request, 'path', ''))
|
||||
@@ -8,6 +8,8 @@ JSON. این هندلر اون حالت رو با traceback کامل لاگ می
|
||||
import logging
|
||||
|
||||
from django.core.exceptions import RequestDataTooBig, SuspiciousOperation
|
||||
|
||||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler as drf_exception_handler
|
||||
|
||||
@@ -25,6 +27,11 @@ def custom_exception_handler(exc, context):
|
||||
# بدنهی بزرگتر از DATA_UPLOAD_MAX_MEMORY_SIZE (≈ ۲٫۵ مگابایت): Django خودش این را ۴۰۰ میکند، ولی
|
||||
# چون DRF آن را نمیشناسد پیشتر اینجا «۵۰۰ + ERRORِ لاگ با traceback» میشد و هر درخواستِ بزرگ لاگ را پر
|
||||
# میکرد (۲۰۲۶-۱۰-۰۷ روی سرورِ زنده دیده شد). باقیِ SuspiciousOperationها هم همان ۴۰۰ِ Django را میگیرند.
|
||||
# poolِ دیتابیس پر (config/db.py): ۵۰۳ با Retry-After. پیش از خواندنِ request.user پایینتر، که اگر
|
||||
# احرازِ هویت خودش همین خطا را داده بود دوباره منتظرِ pool میماند.
|
||||
if is_pool_timeout(exc):
|
||||
log_server_busy(request)
|
||||
return Response(SERVER_BUSY_BODY, status=503, headers={'Retry-After': str(SERVER_BUSY_RETRY_AFTER)})
|
||||
if isinstance(exc, RequestDataTooBig):
|
||||
logger.warning('request_too_large path=%s', path)
|
||||
return Response({'detail': 'حجمِ درخواست بیش از حدِ مجاز است.'}, status=413)
|
||||
|
||||
@@ -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 غلط
|
||||
|
||||
@@ -112,6 +112,33 @@ class APINoStoreMiddleware:
|
||||
return response
|
||||
|
||||
|
||||
class DatabaseBusyMiddleware:
|
||||
"""
|
||||
poolِ دیتابیسِ پر → ۵۰۳ با Retry-After بهجای ۵۰۰ (config/db.py) برای viewهای غیرِ DRF مثلِ /admin/؛
|
||||
viewهای DRF را هندلرِ خطای DRF (config/exceptions.py) جواب میدهد، چون هر خطا را خودش ۵۰۰ میکند
|
||||
و استثنا هیچوقت به اینجا نمیرسد. فقط خطای pool را میگیرد؛ هر OperationalErrorِ دیگری (Postgres
|
||||
پایین، کوئریِ timeoutخورده) همان ۵۰۰ِ قبلی میماند.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
return self.get_response(request)
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
from django.http import JsonResponse
|
||||
|
||||
from config.db import SERVER_BUSY_BODY, SERVER_BUSY_RETRY_AFTER, is_pool_timeout, log_server_busy
|
||||
|
||||
if not is_pool_timeout(exception):
|
||||
return None
|
||||
log_server_busy(request)
|
||||
response = JsonResponse(SERVER_BUSY_BODY, status=503)
|
||||
response['Retry-After'] = str(SERVER_BUSY_RETRY_AFTER)
|
||||
return response
|
||||
|
||||
|
||||
class AdminLoginThrottleMiddleware:
|
||||
"""
|
||||
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
|
||||
|
||||
@@ -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 سنجیده میشود.
|
||||
}
|
||||
|
||||
192
config/tests_db_pool.py
Normal file
192
config/tests_db_pool.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
poolِ اتصالِ Postgres و رفتارِ زیرِ ازدحام (config/settings.py، config/db.py).
|
||||
|
||||
بارِ واقعی با لودتستِ محلی سنجیده شد (Postgresِ ۱۴ با max_connections=60 مثلِ سرور، ۳ worker × ۳۲ نخ،
|
||||
۱۰۰ کاربرِ همزمان): کدِ قبلی ۲۱٪ِ درخواستها را ۵۰۰ کرد و هر ۵۷ اتصال را گرفت؛ با pool صفر خطا و ۲۴ اتصال.
|
||||
این تستها تکههایی را نگه میدارند که بیلودتست هم میشود سنجید.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db import OperationalError, connection, transaction
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase, TransactionTestCase
|
||||
from psycopg_pool import PoolTimeout
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from config import health
|
||||
from config.db import is_pool_timeout, release_db_connection
|
||||
from config.middleware import DatabaseBusyMiddleware
|
||||
from content.models import Grade
|
||||
|
||||
|
||||
def _pool_timeout_error():
|
||||
"""همان شکلی که جنگو PoolTimeout را بالا میدهد (wrap_database_errors)."""
|
||||
err = OperationalError("couldn't get a connection after 10.00 sec")
|
||||
err.__cause__ = PoolTimeout("couldn't get a connection after 10.00 sec")
|
||||
return err
|
||||
|
||||
|
||||
class PoolTimeoutDetectionTests(SimpleTestCase):
|
||||
def test_only_a_wrapped_pool_timeout_counts(self):
|
||||
self.assertTrue(is_pool_timeout(_pool_timeout_error()))
|
||||
self.assertFalse(is_pool_timeout(OperationalError('server closed the connection')))
|
||||
self.assertFalse(is_pool_timeout(PoolTimeout('raw, not wrapped by django')))
|
||||
self.assertFalse(is_pool_timeout(ValueError()))
|
||||
|
||||
|
||||
class ServerBusyResponseTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120007711')
|
||||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||
|
||||
def test_drf_view_answers_503_with_retry_after_not_500(self):
|
||||
# هندلرِ خطای DRF (config/exceptions.py) هر خطای ناشناخته را خودش ۵۰۰ میکند و به میانافزار
|
||||
# نمیرسد؛ اولین نسخهی این تغییر فقط میانافزار را داشت و در لودتست همه ۵۰۰ ماند.
|
||||
with mock.patch('content.views.HomeView.get', side_effect=_pool_timeout_error()), \
|
||||
self.assertLogs('config.db', level='ERROR') as logs:
|
||||
response = self.client.get('/api/home', **self.auth)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response['Retry-After'], '5')
|
||||
self.assertEqual(response.json()['code'], 'server_busy')
|
||||
self.assertIn('db_pool_exhausted', logs.output[0])
|
||||
|
||||
def test_other_database_errors_stay_500(self):
|
||||
with mock.patch('content.views.HomeView.get', side_effect=OperationalError('db down')), \
|
||||
self.assertLogs('config.exceptions', level='ERROR'):
|
||||
response = self.client.get('/api/home', **self.auth)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
def test_middleware_covers_non_drf_views(self):
|
||||
request = RequestFactory().get('/admin/')
|
||||
middleware = DatabaseBusyMiddleware(lambda r: None)
|
||||
with self.assertLogs('config.db', level='ERROR'):
|
||||
response = middleware.process_exception(request, _pool_timeout_error())
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(response['Retry-After'], '5')
|
||||
self.assertIsNone(middleware.process_exception(request, OperationalError('db down')))
|
||||
|
||||
|
||||
class ReleaseConnectionTests(TransactionTestCase):
|
||||
def test_outside_a_transaction_the_connection_goes_back(self):
|
||||
User.objects.count()
|
||||
self.assertIsNotNone(connection.connection)
|
||||
release_db_connection()
|
||||
self.assertIsNone(connection.connection)
|
||||
self.assertEqual(User.objects.count(), 0) # کوئریِ بعدی خودش یکی تازه میگیرد
|
||||
|
||||
def test_inside_atomic_it_keeps_the_connection_and_warns(self):
|
||||
with transaction.atomic():
|
||||
User.objects.count()
|
||||
with self.assertLogs('config.db', level='WARNING'):
|
||||
release_db_connection()
|
||||
self.assertIsNotNone(connection.connection)
|
||||
User.objects.create(phone='09120007712') # تراکنش سالم ماند
|
||||
self.assertEqual(User.objects.count(), 1)
|
||||
|
||||
|
||||
class ReleaseUnderTestCaseIsSilentTests(TestCase):
|
||||
def test_testcase_transaction_is_not_reported_as_a_bug(self):
|
||||
logger = logging.getLogger('config.db')
|
||||
with mock.patch.object(logger, 'warning') as warning:
|
||||
release_db_connection()
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
class AskReleasesTheConnectionBeforeTheModelTests(TransactionTestCase):
|
||||
"""دستیار تا ۴۰ ثانیه منتظرِ Qdrant و مدل است؛ در این مدت نباید اتصالی از pool دستش باشد."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
self.user = User.objects.create(phone='09120007713', grade_id=grade.id)
|
||||
self.seen = {}
|
||||
|
||||
def _ask(self, **extra):
|
||||
return self.client.post(
|
||||
'/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra}, content_type='application/json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
|
||||
)
|
||||
|
||||
def _retrieve(self, *args, **kwargs):
|
||||
self.seen['retrieve'] = connection.connection
|
||||
return [{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'}
|
||||
|
||||
def test_json_path(self):
|
||||
def generate(*args, **kwargs):
|
||||
self.seen['generate'] = connection.connection
|
||||
return 'جواب', [], {}
|
||||
|
||||
with mock.patch('rag.services.config.rag_is_configured', return_value=True), \
|
||||
mock.patch('rag.services.retrieval.retrieve_context', side_effect=self._retrieve), \
|
||||
mock.patch('rag.services.generation.generate_answer', side_effect=generate):
|
||||
response = self._ask()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(self.seen['retrieve'])
|
||||
self.assertIsNone(self.seen['generate'])
|
||||
|
||||
def test_stream_path(self):
|
||||
def stream(*args, **kwargs):
|
||||
self.seen['stream'] = connection.connection
|
||||
yield 'جواب'
|
||||
|
||||
with mock.patch('rag.services.config.rag_is_configured', return_value=True), \
|
||||
mock.patch('rag.services.retrieval.retrieve_context', side_effect=self._retrieve), \
|
||||
mock.patch('rag.services.generation.stream_answer', side_effect=stream):
|
||||
response = self._ask(stream=True)
|
||||
body = b''.join(response.streaming_content).decode()
|
||||
self.assertIn('event: done', body)
|
||||
self.assertIsNone(self.seen['retrieve'])
|
||||
self.assertIsNone(self.seen['stream'])
|
||||
|
||||
|
||||
class PoolBudgetHealthTests(SimpleTestCase):
|
||||
def test_over_budget_is_a_warning(self):
|
||||
warnings = health._db_pool_warnings(
|
||||
{'state': 'over_budget', 'workers': 6, 'poolMaxSize': 10, 'budget': 66, 'maxConnections': 57})
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn('66', warnings[0])
|
||||
self.assertEqual(health._db_pool_warnings({'state': 'ok'}), [])
|
||||
self.assertEqual(health._db_pool_warnings({'state': 'not_postgres'}), [])
|
||||
|
||||
|
||||
class PoolBudgetOnPostgresTests(TestCase):
|
||||
def test_reports_the_real_limits(self):
|
||||
if connection.vendor != 'postgresql':
|
||||
self.skipTest('pool فقط روی Postgres است')
|
||||
with mock.patch.dict(os.environ, {'GUNICORN_WORKERS': '4'}):
|
||||
state = health._db_pool()
|
||||
self.assertEqual(state['workers'], 4)
|
||||
self.assertEqual(state['budget'], 4 * state['poolMaxSize'] + health.DB_CONNECTION_RESERVE)
|
||||
self.assertGreater(state['maxConnections'], 0)
|
||||
self.assertIn(state['state'], ('ok', 'over_budget'))
|
||||
|
||||
|
||||
class PoolSettingsFailLoudlyTests(SimpleTestCase):
|
||||
"""تنظیمِ غلط باید سرویس را بالا نیاورد، نه اینکه سرِ اولین کوئری همهی درخواستها ۵۰۰ شوند."""
|
||||
|
||||
def _load_settings(self, **env):
|
||||
base = {k: v for k, v in os.environ.items() if not k.startswith('DB_')}
|
||||
base.update({'DB_NAME': 'x', 'DB_USER': 'x', 'DB_PASSWORD': 'x', **env})
|
||||
return subprocess.run(
|
||||
[sys.executable, '-c', 'import config.settings'],
|
||||
env=base, capture_output=True, text=True, cwd=os.path.dirname(os.path.dirname(__file__)),
|
||||
)
|
||||
|
||||
def test_good_settings_load(self):
|
||||
self.assertEqual(self._load_settings().returncode, 0)
|
||||
|
||||
def test_old_conn_max_age_is_refused(self):
|
||||
result = self._load_settings(DB_CONN_MAX_AGE='60')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('DB_CONN_MAX_AGE', result.stderr)
|
||||
|
||||
def test_min_above_max_is_refused(self):
|
||||
result = self._load_settings(DB_POOL_MIN_SIZE='3', DB_POOL_MAX_SIZE='2')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('DB_POOL_MIN_SIZE', result.stderr)
|
||||
@@ -37,4 +37,7 @@ class OffsiteWarningTests(SimpleTestCase):
|
||||
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
|
||||
|
||||
def test_sqlite_reports_not_postgres(self):
|
||||
# سوئیت روی Postgres هم اجرا میشود (پیش از هر تغییرِ دیتابیسی)؛ آنجا این حالت معنی ندارد.
|
||||
if health.connection.vendor != 'sqlite':
|
||||
self.skipTest('فقط روی SQLite')
|
||||
self.assertEqual(health._offsite_backup(), {'state': 'not_postgres'})
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 را میدهد |
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
| ۳ | دادهی دمو روی دیتابیسِ پروداکشن | بالا | دیتابیسِ سرور |
|
||||
| ۷ | پنلِ `/admin/` روی دامنهی عمومی | متوسط | nginx + کد |
|
||||
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب میدهد | متوسط | ادمینِ سرور |
|
||||
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقفهای مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
|
||||
| ۱۱ | کدِ OTP بیهش در دیتابیس و در فهرستِ پنل | کم | کد |
|
||||
| ۱۲ | هزینهی دستیارِ AI با ساختِ شمارهی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
|
||||
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
||||
@@ -63,7 +62,7 @@
|
||||
## ۷. پنلِ ادمین روی دامنهی عمومی
|
||||
|
||||
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نامکاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
|
||||
ولی: شمارش per-worker است (مورد ۹)، IP با XFF جعلپذیر است (مورد ۸)، و ۲FA ندارد.
|
||||
ولی: IP با XFF جعلپذیر است (مورد ۸)، و ۲FA ندارد.
|
||||
- **خطر:** ادمین همهی محتوا، پلنها، کدهای تخفیف، سفارشها و کاربرها را کنترل میکند.
|
||||
- **رفع:** محدود کردنِ `/admin/` در nginx به IPهای تیم یا VPN؛ تغییرِ مسیرِ پیشفرض؛ ۲FA؛
|
||||
رمزهای قوی و حسابِ staffِ جدا برای هر نفر.
|
||||
@@ -83,18 +82,6 @@
|
||||
درخواست دور زده میشود، و حفاظتِ DDoS/WAFِ آروان هم برای این مسیر وجود ندارد. سقفهای
|
||||
per-phone و per-code که در دیتابیس شمرده میشوند متأثر نیستند. پورتهای ۸۰ و ۴۴۳ هر دو باز بودند.
|
||||
|
||||
## ۹. کشِ مشترک بینِ workerها نیست
|
||||
|
||||
- **چیست:** `CACHE_URL` روی سرور خالی است و کش `LocMemCache` است؛ ۳ worker هرکدام جدا میشمارند، پس سقفهای
|
||||
مبتنی بر کش (throttleهای DRF، پنجرهی ساعتیِ OTP، سقفِ ورودِ ادمین) ≈ ۳ برابرِ عددِ تنظیمشدهاند و با ریاستارت
|
||||
صفر میشوند. سقفهای دیتابیسی (cooldown و سقفِ روزانهی پیامک، حدسِ کد) درستاند.
|
||||
- **چرا باز مانده:** Redisِ روی ماشین مالِ تیمهای دیگر است و تصمیمِ مالک (۲۰۲۶-۱۰-۰۷) این است که به آن، یا به
|
||||
هیچ سرویسِ دیگرانی، وصل نشویم.
|
||||
- **رفع:** Redisِ اختصاصیِ خودمان (نمونهی جدا، فقط localhost، با رمز؛ ساختنش با مدیرِ سرور یا یک سرویسِ بیرونی)،
|
||||
بعد `CACHE_URL`. کد آماده است: `config/cache.py` (قطعیِ Redis API را ۵۰۰ نمیکند).
|
||||
- **چطور بسنجید:** `/api/health` باید `LocMemCache` نگوید و هشدارِ کش نداشته باشد؛ یک رگبارِ موازی روی یک مسیرِ
|
||||
throttleدار باید حدودِ یکبارِ سقف (نه سه برابر) بپذیرد.
|
||||
|
||||
## ۱۱. کدِ OTP بیهش در دیتابیس
|
||||
|
||||
- **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده میشود.
|
||||
@@ -227,7 +214,7 @@
|
||||
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |
|
||||
| پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبتشده به همهی سوالها میخواهند (۴۰۹ `answers_missing`)، روی جزیرهی قفل ۴۰۳ `node_locked` میدهند (همان قاعدهی نقشه)، جزیره یا درسِ بیسوال چیزی برای جوابدادن ندارد و اگر باز است بیامتیاز رد میشود (قفل بودنش هنوز چک میشود)، و تعدادِ سوالِ آزمون را کلاینت نمیتواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستارهی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` |
|
||||
| کلیدِ امضای توکن تصادفی و فقط روی سرور است (`.django_secret_key`، ۶۰۰)؛ نه در `.env`، نه در یونیت؛ توکنِ ساختهشده با کلیدِ داخلِ ریپو `401` میگیرد؛ `DJANGO_DEBUG` هم در یونیت نیست | دستی (۲۰۲۶-۱۰-۰۷): توکنِ جعلی `200` ← `401`؛ تستِ واحد ندارد |
|
||||
| با `CACHE_URL` ستشده، قطعیِ Redis API را ۵۰۰ نمیکند (fail-open، ERROR در لاگ، health ۵۰۳؛ شرح در `config/cache.py`). الان ست نیست | `config/tests_cache_resilience.py` (۱۱ تست، با redis-serverِ محلی که وسطِ کار میمیرد) |
|
||||
| با `CACHE_URL` ستشده، قطعیِ Redis API را ۵۰۰ نمیکند (fail-open، ERROR در لاگ، health ۵۰۳؛ شرح در `config/cache.py`). روی سرور به Redisِ اختصاصیِ خودمان (`zareian-redis`) ست است، پس سقفهای مبتنی بر کش بینِ workerها مشترکاند؛ بکاندِ `ResilientRedisCache` روی سرور دیده شد، رگبارِ موازی برای سنجیدنِ سقفِ واحد زده نشد | `config/tests_cache_resilience.py` (۱۱ تست، با redis-serverِ محلی که وسطِ کار میمیرد) |
|
||||
| بدنهٔ درخواست بزرگتر از ≈ ۲٫۵ مگابایت `413` میگیرد و ERRORِ لاگ نمیسازد (پیشتر `500` + traceback در لاگ؛ `RequestDataTooBig`). nginx هنوز تا ۱۰۰ مگابایت بافر میکند (بند ۱۸) | `config/tests_request_size.py` |
|
||||
| کتابخانهها بدونِ آسیبپذیریِ شناختهشده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |
|
||||
| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسیشده» و لینکِ بازار اجازهی تغییر میخواهند؛ کارمندِ فقطخواندنی (Supervisor) اجرایشان نمیکند | `payment/tests_security_review.py`، `content/tests_security_review.py` |
|
||||
|
||||
@@ -20,9 +20,16 @@ gthread هر worker را چند نخی میکند: ۳ worker × ۸ نخ = ۲
|
||||
نمیشوند، پس با نخِ کم، صفحههای عادیِ اپ پشتِ استریمهای دستیار صف میکشند. در لودتستِ
|
||||
۲۰۲۶-۱۰-۰۴ (لپتاپ، مدل و Qdrantِ شبیهسازیشده با زمانبندیِ سرور) ۳×۳۲ تا ۷۵ پرسشِ همزمان
|
||||
صفحهها را زیرِ ۰٫۱ ثانیه نگه داشت و از ۱۰۰ به بعد صف پیدا شد. CPUِ مشترکِ سرور در این بار
|
||||
سنجیده نشده. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ هر نخ اتصالِ SQLiteِ خودش را دارد.
|
||||
سنجیده نشده. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ اتصالِ دیتابیس را فقط حینِ کار از poolِ همان
|
||||
worker قرض میگیرد (config/settings.py)، پس تعدادِ نخ به سقفِ اتصالِ Postgres ربطی ندارد.
|
||||
"""
|
||||
|
||||
# ۴ worker: هر worker با GIL حداکثر یک هسته است، پس گذرش با تعدادِ worker بالا میرود (لپتاپ، ۱۰۰
|
||||
# کاربرِ همزمان، ۲۰۲۶-۱۰-۱۱: ۳ → ۵۵۰، ۴ → ۶۲۰، ۶ → ۸۱۰ درخواست در ثانیه). ماشین مشترک است: همان روز
|
||||
# بقیهی تیمها ~۵۵٪ِ ۸ هسته را مصرف میکردند (load ≈ ۵)، پس ۴ و نه ۶، تا در اوجِ ما سرویسِ دیگران
|
||||
# گرسنه نماند. هر worker ~۱۰۰ مگابایت. بودجهی اتصالِ دیتابیس: ۴ × DB_POOL_MAX_SIZE(۸) + ۶ = ۳۸ از ۵۷.
|
||||
# این عدد فقط وقتی اثر دارد که یونیتِ systemd خودش `--workers` نداشته باشد (خطِ فرمان مقدم است).
|
||||
workers = 4
|
||||
worker_class = 'gthread'
|
||||
threads = 32
|
||||
timeout = 120
|
||||
@@ -35,6 +42,17 @@ max_requests = 2000
|
||||
max_requests_jitter = 200
|
||||
|
||||
|
||||
def post_fork(server, worker):
|
||||
"""
|
||||
تعدادِ واقعیِ workerها (با `--workers`ِ خطِ فرمانِ unit، که بر این فایل مقدم است) را به جنگو
|
||||
میدهد تا /api/health بودجهی اتصالِ دیتابیس را درست بسنجد: workers × DB_POOL_MAX_SIZE باید زیرِ
|
||||
max_connectionsِ Postgres بماند (config/health.py، _db_pool). پیش از بار شدنِ اپ اجرا میشود.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['GUNICORN_WORKERS'] = str(server.cfg.workers)
|
||||
|
||||
|
||||
def post_worker_init(worker):
|
||||
"""
|
||||
کتابخانههای Qdrant و OpenAI را همان لحظهی بالا آمدنِ worker بار میکند، نه سرِ اولین
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -47,3 +48,35 @@ def compute_scores(period, days=7, limit=50, include_zero=False):
|
||||
scores = [(uid, xp) for uid, xp in totals.items() if include_zero or xp > 0]
|
||||
scores.sort(key=lambda p: (-p[1], p[0]))
|
||||
return scores[:limit]
|
||||
|
||||
|
||||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را از نو حساب نکنند.
|
||||
LEADERBOARD_CACHE_TTL = 60
|
||||
# نسخهی کهنه فقط برای لحظهای است که کسی دیگر در حالِ ساختنِ نسخهی تازه است.
|
||||
_STALE_TTL = 24 * 3600
|
||||
_LOCK_TTL = 30
|
||||
|
||||
|
||||
def cached_scores(period):
|
||||
"""
|
||||
compute_scores با کش، بی «ازدحامِ لحظهی انقضا»: با `get_or_set` ساده، وقتی کش منقضی میشد هر
|
||||
درخواستی که در همان لحظه میرسید خودش کلِ جدول را از نو حساب میکرد. حالا فقط درخواستی که قفل را
|
||||
بگیرد حساب میکند و بقیه آخرین نسخه را (حداکثر چند ثانیه کهنهتر) میگیرند. اگر هیچ نسخهای نیست
|
||||
(اولین بار یا کشِ خالیشده) خودشان حساب میکنند، چون چیزِ دیگری برای نشان دادن نیست.
|
||||
کش Redisِ مشترکِ workerهاست؛ با قطعیِ Redis (config/cache.py) هر درخواست مستقیم حساب میکند.
|
||||
"""
|
||||
fresh_key, stale_key, lock_key = (f'leaderboard:{period}', f'leaderboard:{period}:stale',
|
||||
f'leaderboard:{period}:lock')
|
||||
scores = cache.get(fresh_key)
|
||||
if scores is not None:
|
||||
return scores
|
||||
if cache.add(lock_key, 1, _LOCK_TTL):
|
||||
try:
|
||||
scores = compute_scores(period)
|
||||
cache.set(fresh_key, scores, LEADERBOARD_CACHE_TTL)
|
||||
cache.set(stale_key, scores, _STALE_TTL)
|
||||
finally:
|
||||
cache.delete(lock_key)
|
||||
return scores
|
||||
stale = cache.get(stale_key)
|
||||
return stale if stale is not None else compute_scores(period)
|
||||
|
||||
43
progress/tests_leaderboard_cache.py
Normal file
43
progress/tests_leaderboard_cache.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
کشِ لیدربورد بی «ازدحامِ لحظهی انقضا» (progress/leaderboard.py، cached_scores): وقتی کش منقضی میشود
|
||||
فقط یک درخواست جدول را از نو حساب میکند و بقیه آخرین نسخه را میگیرند. همزمانیِ واقعی بینِ workerها
|
||||
آزموده نشده؛ این تستها قفل را با گذاشتنِ دستیِ کلیدش شبیهسازی میکنند.
|
||||
"""
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from progress import leaderboard
|
||||
|
||||
|
||||
class CachedScoresTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_first_call_computes_and_fills_both_copies(self):
|
||||
with mock.patch.object(leaderboard, 'compute_scores', return_value=[(1, 50)]) as compute:
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(1, 50)])
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(1, 50)])
|
||||
compute.assert_called_once_with('weekly')
|
||||
self.assertEqual(cache.get('leaderboard:weekly:stale'), [(1, 50)])
|
||||
self.assertIsNone(cache.get('leaderboard:weekly:lock'))
|
||||
|
||||
def test_while_someone_else_recomputes_the_stale_copy_is_served(self):
|
||||
cache.set('leaderboard:weekly:stale', [(2, 30)], 600)
|
||||
cache.set('leaderboard:weekly:lock', 1, 30)
|
||||
with mock.patch.object(leaderboard, 'compute_scores') as compute:
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(2, 30)])
|
||||
compute.assert_not_called()
|
||||
|
||||
def test_without_any_copy_it_computes_even_if_locked(self):
|
||||
cache.set('leaderboard:weekly:lock', 1, 30)
|
||||
with mock.patch.object(leaderboard, 'compute_scores', return_value=[(3, 10)]) as compute:
|
||||
self.assertEqual(leaderboard.cached_scores('weekly'), [(3, 10)])
|
||||
compute.assert_called_once()
|
||||
|
||||
def test_a_failed_recompute_releases_the_lock(self):
|
||||
with mock.patch.object(leaderboard, 'compute_scores', side_effect=RuntimeError('db')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
leaderboard.cached_scores('weekly')
|
||||
self.assertIsNone(cache.get('leaderboard:weekly:lock'))
|
||||
@@ -109,11 +109,6 @@ class WeeklyReportView(APIView):
|
||||
return Response(WeeklyReportResponseSerializer(weekly_report(request.user.id)).data)
|
||||
|
||||
|
||||
# کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ همزمان هر بار جدول را
|
||||
# از نو حساب نکنند.
|
||||
LEADERBOARD_CACHE_TTL = 60
|
||||
|
||||
|
||||
class LeaderboardView(APIView):
|
||||
"""
|
||||
GET /api/leaderboard?period= — رتبهبندی هفتگی/ماهانه
|
||||
@@ -142,12 +137,8 @@ class LeaderboardView(APIView):
|
||||
# زنده از فعالیتِ واقعی، با کشِ کوتاه (progress/leaderboard.py). قبلاً از
|
||||
# جدولِ LeaderboardEntry خوانده میشد که فقط با اجرای دستیِ
|
||||
# rebuild_leaderboard پر میشد، پس XPی کاربرِ تازه هیچوقت دیده نمیشد.
|
||||
from django.core.cache import cache
|
||||
|
||||
from .leaderboard import compute_scores
|
||||
scores = cache.get_or_set(
|
||||
f'leaderboard:{period}', lambda: compute_scores(period), LEADERBOARD_CACHE_TTL,
|
||||
)
|
||||
from .leaderboard import cached_scores
|
||||
scores = cached_scores(period)
|
||||
# نام کاربر از accounts.User خونده میشه (فقط برای نمایش، بدون FK واقعی).
|
||||
from accounts.models import User as AccountUser
|
||||
names = dict(
|
||||
|
||||
@@ -25,3 +25,6 @@ pymupdf==1.28.2
|
||||
pdf2image==1.17.0
|
||||
pillow==12.3.0
|
||||
psycopg[binary]==3.3.6
|
||||
# pool اتصالِ Postgres (OPTIONS.pool در config/settings.py). یک بار با pip نصب میشود؛ deploy.sh در
|
||||
# دیپلوهای بعد فقط «already satisfied» میگوید.
|
||||
psycopg-pool==3.3.3
|
||||
|
||||
Reference in New Issue
Block a user