From feaa5eb540f6ab9d3cff567753a20e32a85f39ee Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 11 Oct 2026 20:21:35 +0330 Subject: [PATCH] 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 --- .env.example | 6 + CLAUDE.md | 2 +- .../0010_alter_otpcode_expires_at.py | 18 ++ accounts/models.py | 4 +- accounts/views.py | 5 + .../0008_alter_message_created_at.py | 18 ++ ai_tutor/models.py | 4 +- ai_tutor/views.py | 5 + config/db.py | 53 +++++ config/exceptions.py | 7 + config/health.py | 66 +++++- config/middleware.py | 27 +++ config/settings.py | 46 ++++- config/tests_db_pool.py | 192 ++++++++++++++++++ config/tests_offsite_backup.py | 3 + ...serlessoncompletion_created_at_and_more.py | 23 +++ content/models.py | 8 +- docs/operations.md | 4 +- docs/security-before-production.md | 17 +- gunicorn.conf.py | 20 +- progress/leaderboard.py | 33 +++ progress/tests_leaderboard_cache.py | 43 ++++ progress/views.py | 13 +- requirements.txt | 3 + 24 files changed, 585 insertions(+), 35 deletions(-) create mode 100644 accounts/migrations/0010_alter_otpcode_expires_at.py create mode 100644 ai_tutor/migrations/0008_alter_message_created_at.py create mode 100644 config/db.py create mode 100644 config/tests_db_pool.py create mode 100644 content/migrations/0013_alter_userlessoncompletion_created_at_and_more.py create mode 100644 progress/tests_leaderboard_cache.py diff --git a/.env.example b/.env.example index 8296f5a..6426d3d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CLAUDE.md b/CLAUDE.md index 2e2dc80..ff21cb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/accounts/migrations/0010_alter_otpcode_expires_at.py b/accounts/migrations/0010_alter_otpcode_expires_at.py new file mode 100644 index 0000000..cb4c754 --- /dev/null +++ b/accounts/migrations/0010_alter_otpcode_expires_at.py @@ -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), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index ef73492..5ce1d84 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -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 کد باطل می‌شود. diff --git a/accounts/views.py b/accounts/views.py index 0efb813..6419c79 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -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: diff --git a/ai_tutor/migrations/0008_alter_message_created_at.py b/ai_tutor/migrations/0008_alter_message_created_at.py new file mode 100644 index 0000000..9432ece --- /dev/null +++ b/ai_tutor/migrations/0008_alter_message_created_at.py @@ -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), + ), + ] diff --git a/ai_tutor/models.py b/ai_tutor/models.py index f0be33e..2935543 100644 --- a/ai_tutor/models.py +++ b/ai_tutor/models.py @@ -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') -------------- # سینیور خواست لایک/دیسلایک بعداً قابلِ تحلیل باشد: «کدام جواب بهتر بوده». diff --git a/ai_tutor/views.py b/ai_tutor/views.py index e5a98a6..9962bc6 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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, diff --git a/config/db.py b/config/db.py new file mode 100644 index 0000000..6e9a6bc --- /dev/null +++ b/config/db.py @@ -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', '')) diff --git a/config/exceptions.py b/config/exceptions.py index e38e0b2..d5395d2 100644 --- a/config/exceptions.py +++ b/config/exceptions.py @@ -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) diff --git a/config/health.py b/config/health.py index 2202684..b4001f9 100644 --- a/config/health.py +++ b/config/health.py @@ -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 غلط diff --git a/config/middleware.py b/config/middleware.py index 7f4b0a1..1919967 100644 --- a/config/middleware.py +++ b/config/middleware.py @@ -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 در ۱۵ دقیقه). diff --git a/config/settings.py b/config/settings.py index 2e48b2b..5f9f0f5 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 سنجیده می‌شود. } diff --git a/config/tests_db_pool.py b/config/tests_db_pool.py new file mode 100644 index 0000000..6007275 --- /dev/null +++ b/config/tests_db_pool.py @@ -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) diff --git a/config/tests_offsite_backup.py b/config/tests_offsite_backup.py index 4fca963..5c10e40 100644 --- a/config/tests_offsite_backup.py +++ b/config/tests_offsite_backup.py @@ -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'}) diff --git a/content/migrations/0013_alter_userlessoncompletion_created_at_and_more.py b/content/migrations/0013_alter_userlessoncompletion_created_at_and_more.py new file mode 100644 index 0000000..559a8bd --- /dev/null +++ b/content/migrations/0013_alter_userlessoncompletion_created_at_and_more.py @@ -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), + ), + ] diff --git a/content/models.py b/content/models.py index 6990375..c6df557 100644 --- a/content/models.py +++ b/content/models.py @@ -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 = [ diff --git a/docs/operations.md b/docs/operations.md index cb5c7cf..1f9ca09 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 را می‌دهد | diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 9b4c92b..6a383fc 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -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` | diff --git a/gunicorn.conf.py b/gunicorn.conf.py index f4ca5c3..bf6e5b8 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.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 بار می‌کند، نه سرِ اولین diff --git a/progress/leaderboard.py b/progress/leaderboard.py index 5addd5f..9633c8b 100644 --- a/progress/leaderboard.py +++ b/progress/leaderboard.py @@ -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) diff --git a/progress/tests_leaderboard_cache.py b/progress/tests_leaderboard_cache.py new file mode 100644 index 0000000..c628578 --- /dev/null +++ b/progress/tests_leaderboard_cache.py @@ -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')) diff --git a/progress/views.py b/progress/views.py index 869a10e..18e1af2 100644 --- a/progress/views.py +++ b/progress/views.py @@ -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( diff --git a/requirements.txt b/requirements.txt index 64626bb..94668cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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