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

@@ -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=

View File

@@ -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.

View 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),
),
]

View File

@@ -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 کد باطل می‌شود.

View File

@@ -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:

View 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),
),
]

View File

@@ -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') --------------
# سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده».

View File

@@ -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
View 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', ''))

View File

@@ -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)

View File

@@ -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 غلط

View File

@@ -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 در ۱۵ دقیقه).

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

192
config/tests_db_pool.py Normal file
View 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)

View File

@@ -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'})

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 = [

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 را می‌دهد |

View File

@@ -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` |

View File

@@ -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 بار می‌کند، نه سرِ اولین

View File

@@ -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)

View 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'))

View File

@@ -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(

View File

@@ -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