Spike readiness: AI gets its own slice of threads, cheaper hot paths, classroom-friendly login
- /api/ai/ask: at most AI_MAX_CONCURRENT_PER_WORKER (12 of 32 threads) answers per worker; beyond that 503 assistant_busy + Retry-After before any quota is reserved. Without it a burst of questions (3-40 s each) took every thread and the map and quizzes queued behind. The slot is held until the stream is actually finished or closed. - Answer submission 21 -> 14 queries, 15.4 -> 11.0 ms: one query for all choices, locked get_or_create for node progress and streak, and today's streak recorded once per day via the cache instead of a transaction per answer. - /api/home 12.4 -> 8.6 ms: the user's completions are read by user_id instead of an IN list of ~600 lesson ids; the dict is returned directly (serializer stays the Swagger contract, HomeMatchesItsSerializerTests keeps them equal). - otp_login per-IP 30 -> 300/hour: a class of 30 on one school Wi-Fi filled it; guessing is bounded per code and per phone. - /api/health aiConcurrency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
**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. **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.
|
**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`). **The AI tutor has its own slice of threads** (`AI_MAX_CONCURRENT_PER_WORKER`, 12 of 32, `ai_tutor/concurrency.py`): beyond that `/api/ai/ask` answers 503 `Retry-After: 10` before reserving quota, so a chat spike cannot starve the map and quizzes. The frontend spreads retries over 1–2× `Retry-After`. 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.
|
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||||||
|
|
||||||
|
|||||||
76
ai_tutor/concurrency.py
Normal file
76
ai_tutor/concurrency.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
سقفِ جوابهای همزمانِ دستیار در هر worker (AI_MAX_CONCURRENT_PER_WORKER).
|
||||||
|
|
||||||
|
جوابِ دستیار یک نخِ gunicorn را تا پایانِ کار (۳ تا ۴۰ ثانیه، منتظرِ Qdrant و مدل) نگه میدارد. بدونِ
|
||||||
|
سقف، اگر چند ده دانشآموز با هم سوال بپرسند همهی نخهای worker گرفته میشوند و نقشه، آزمون و ثبتِ
|
||||||
|
جواب (که هرکدام چند میلیثانیهاند) پشتِ آنها صف میکشند: یک بخشِ کند کلِ اپ را میخواباند. این همان
|
||||||
|
الگوی bulkhead است: هر بخش سهمِ جدای خودش را دارد. درخواستی که جا نگیرد فوراً ۵۰۳ میگیرد.
|
||||||
|
|
||||||
|
سمافور مالِ همین پروسه است (هر worker جدا). همزمانیِ واقعی زیرِ بارِ gunicorn با مدلِ واقعی آزموده نشده؛
|
||||||
|
تستها رفتارِ گرفتن/پسدادن را در مسیرِ JSON و استریم نگه میدارند (ai_tutor/tests_concurrency.py).
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_in_flight = 0
|
||||||
|
|
||||||
|
|
||||||
|
def limit() -> int:
|
||||||
|
return settings.AI_MAX_CONCURRENT_PER_WORKER
|
||||||
|
|
||||||
|
|
||||||
|
def try_acquire() -> bool:
|
||||||
|
"""بیانتظار: یا جا هست و گرفته میشود، یا False. انتظار نخ را همانطور نگه میداشت که میخواهیم نشود."""
|
||||||
|
global _in_flight
|
||||||
|
with _lock:
|
||||||
|
if _in_flight >= limit():
|
||||||
|
return False
|
||||||
|
_in_flight += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def release() -> None:
|
||||||
|
global _in_flight
|
||||||
|
with _lock:
|
||||||
|
if _in_flight <= 0:
|
||||||
|
# پسدادنِ بیگرفتن یعنی باگ در جفتشدنِ گرفتن/پسدادن؛ شمارنده منفی نمیشود تا سقف بیاثر نشود.
|
||||||
|
raise RuntimeError('ai concurrency slot released more times than acquired')
|
||||||
|
_in_flight -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def in_flight() -> int:
|
||||||
|
return _in_flight
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseWhenDone:
|
||||||
|
"""
|
||||||
|
محتوای استریم را میپیچد تا جا وقتی پس داده شود که استریم واقعاً تمام شد: یا با پایانِ خواندن،
|
||||||
|
یا با close() که جنگو هنگامِ بستنِ پاسخ صدا میزند (کاربر وسطِ جواب صفحه را بست، یا پاسخ اصلاً
|
||||||
|
خوانده نشد). generatorی که شروع نشده با close() بلوکِ finallyاش را اجرا نمیکند؛ برای همین
|
||||||
|
پسدادن اینجاست، نه داخلِ generator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, iterable):
|
||||||
|
self._iterable = iterable
|
||||||
|
self._released = False
|
||||||
|
|
||||||
|
def _release_once(self):
|
||||||
|
if not self._released:
|
||||||
|
self._released = True
|
||||||
|
release()
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
try:
|
||||||
|
yield from self._iterable
|
||||||
|
finally:
|
||||||
|
self._release_once()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
close = getattr(self._iterable, 'close', None)
|
||||||
|
if close is not None:
|
||||||
|
close()
|
||||||
|
finally:
|
||||||
|
self._release_once()
|
||||||
92
ai_tutor/tests_concurrency.py
Normal file
92
ai_tutor/tests_concurrency.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
سقفِ جوابهای همزمانِ دستیار (ai_tutor/concurrency.py): رگبارِ سوال نباید همهی نخهای worker را بگیرد.
|
||||||
|
همزمانیِ واقعیِ چند نخ با مدلِ واقعی آزموده نشده؛ اینجا جا را با گرفتنِ دستی پر میکنیم.
|
||||||
|
"""
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.core.signals import request_finished
|
||||||
|
from django.db import close_old_connections
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from accounts.authentication import issue_access_token
|
||||||
|
from accounts.models import User
|
||||||
|
from ai_tutor import concurrency
|
||||||
|
from ai_tutor.models import Message
|
||||||
|
from content.models import Grade
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AI_MAX_CONCURRENT_PER_WORKER=1)
|
||||||
|
class AskConcurrencyCapTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||||
|
self.user = User.objects.create(phone='09120007801', grade_id=grade.id)
|
||||||
|
self.assertEqual(concurrency.in_flight(), 0)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.assertEqual(concurrency.in_flight(), 0, 'هر تست باید جا را پس بدهد')
|
||||||
|
|
||||||
|
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 _rag(self):
|
||||||
|
return (
|
||||||
|
mock.patch('rag.services.config.rag_is_configured', return_value=True),
|
||||||
|
mock.patch('rag.services.retrieval.retrieve_context',
|
||||||
|
return_value=([{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'})),
|
||||||
|
mock.patch('rag.services.generation.generate_answer', return_value=('جواب', [], {})),
|
||||||
|
mock.patch('rag.services.generation.stream_answer', side_effect=lambda *a, **k: iter(['جو', 'اب'])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_means_503_with_retry_after_and_no_quota_used(self):
|
||||||
|
self.assertTrue(concurrency.try_acquire()) # یک سوالِ دیگر در حالِ جواب است
|
||||||
|
try:
|
||||||
|
with self.assertLogs('ai_tutor.views', level='WARNING'):
|
||||||
|
response = self._ask()
|
||||||
|
finally:
|
||||||
|
concurrency.release()
|
||||||
|
self.assertEqual(response.status_code, 503)
|
||||||
|
self.assertEqual(response.json()['detail'], 'دستیار الان خیلی شلوغه؛ چند ثانیهی دیگه دوباره بپرس.')
|
||||||
|
self.assertEqual(response['Retry-After'], '10')
|
||||||
|
self.assertFalse(Message.objects.exists()) # نه پیامی ساخته شد نه سهمیهای رزرو شد
|
||||||
|
|
||||||
|
def test_json_answer_gives_the_slot_back(self):
|
||||||
|
a, b, c, d = self._rag()
|
||||||
|
with a, b, c, d:
|
||||||
|
self.assertEqual(self._ask().status_code, 200)
|
||||||
|
self.assertEqual(concurrency.in_flight(), 0)
|
||||||
|
self.assertEqual(self._ask().status_code, 200) # سقفِ ۱: دومی هم جا گرفت
|
||||||
|
|
||||||
|
def test_a_failed_answer_gives_the_slot_back(self):
|
||||||
|
a, b, _, d = self._rag()
|
||||||
|
failing = mock.patch('rag.services.generation.generate_answer', side_effect=RuntimeError('provider'))
|
||||||
|
with a, b, failing, d, self.assertLogs('ai_tutor.views', level='ERROR'):
|
||||||
|
self.assertEqual(self._ask().status_code, 503)
|
||||||
|
|
||||||
|
def test_stream_holds_the_slot_until_it_is_read(self):
|
||||||
|
a, b, c, d = self._rag()
|
||||||
|
with a, b, c, d:
|
||||||
|
response = self._ask(stream=True)
|
||||||
|
self.assertEqual(concurrency.in_flight(), 1) # هنوز خوانده نشده
|
||||||
|
body = b''.join(response.streaming_content).decode() # کلاینتِ تست پایانش close() میزند
|
||||||
|
self.assertIn('event: done', body)
|
||||||
|
|
||||||
|
def test_a_stream_that_is_never_read_still_gives_the_slot_back(self):
|
||||||
|
a, b, c, d = self._rag()
|
||||||
|
with a, b, c, d:
|
||||||
|
response = self._ask(stream=True)
|
||||||
|
# کاربر پیش از اولین تکه رفت. سیگنالِ request_finished در TestCase اتصالِ تستی را میبندد؛ کلاینتِ
|
||||||
|
# تست هم برای پاسخِ استریم همینطور جدایش میکند.
|
||||||
|
request_finished.disconnect(close_old_connections)
|
||||||
|
try:
|
||||||
|
response.close()
|
||||||
|
finally:
|
||||||
|
request_finished.connect(close_old_connections)
|
||||||
|
|
||||||
|
def test_release_without_acquire_is_a_loud_bug(self):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
concurrency.release()
|
||||||
@@ -17,7 +17,7 @@ from rest_framework.views import APIView
|
|||||||
|
|
||||||
from accounts.models import User as AccountUser
|
from accounts.models import User as AccountUser
|
||||||
from config.db import release_db_connection
|
from config.db import release_db_connection
|
||||||
from . import provider_status
|
from . import concurrency, provider_status
|
||||||
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
from .models import AskIdempotency, Conversation, Message, MessageFeedback
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
QuotaResponseSerializer,
|
QuotaResponseSerializer,
|
||||||
@@ -256,6 +256,15 @@ def generate_tutor_answer(
|
|||||||
return answer, follow_ups
|
return answer, follow_ups
|
||||||
|
|
||||||
|
|
||||||
|
class AIBusy(APIException):
|
||||||
|
"""همهی جاهای همزمانِ دستیار در این worker پرند (ai_tutor/concurrency.py). سهمیه دست نخورده."""
|
||||||
|
status_code = 503
|
||||||
|
default_detail = 'دستیار الان خیلی شلوغه؛ چند ثانیهی دیگه دوباره بپرس.'
|
||||||
|
default_code = 'assistant_busy'
|
||||||
|
# هندلرِ DRF از `wait` هدرِ Retry-After میسازد.
|
||||||
|
wait = 10
|
||||||
|
|
||||||
|
|
||||||
class AskAIView(APIView):
|
class AskAIView(APIView):
|
||||||
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
|
||||||
|
|
||||||
@@ -282,6 +291,24 @@ class AskAIView(APIView):
|
|||||||
tags=['AI Tutor (ai_tutor)'],
|
tags=['AI Tutor (ai_tutor)'],
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
# پیش از هر کارِ دیگری، حتی رزروِ سهمیه: درخواستِ ردشده نباید از سهمیهی روزانه کم کند.
|
||||||
|
if not concurrency.try_acquire():
|
||||||
|
logger.warning('ai_busy user_id=%s in_flight=%s limit=%s',
|
||||||
|
request.user.id, concurrency.in_flight(), concurrency.limit())
|
||||||
|
raise AIBusy()
|
||||||
|
try:
|
||||||
|
response = self._ask(request)
|
||||||
|
except BaseException:
|
||||||
|
concurrency.release()
|
||||||
|
raise
|
||||||
|
if isinstance(response, StreamingHttpResponse):
|
||||||
|
# جا تا پایانِ استریم دستِ همین درخواست میماند (ReleaseWhenDone).
|
||||||
|
response.streaming_content = concurrency.ReleaseWhenDone(response.streaming_content)
|
||||||
|
else:
|
||||||
|
concurrency.release()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _ask(self, request):
|
||||||
serializer = AskRequestSerializer(data=request.data)
|
serializer = AskRequestSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
v = serializer.validated_data
|
v = serializer.validated_data
|
||||||
|
|||||||
@@ -237,6 +237,12 @@ def _db_pool() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_concurrency() -> dict:
|
||||||
|
from ai_tutor import concurrency
|
||||||
|
|
||||||
|
return {'maxPerWorker': concurrency.limit(), 'inFlightThisWorker': concurrency.in_flight()}
|
||||||
|
|
||||||
|
|
||||||
def _db_pool_warnings(pool: dict) -> list[str]:
|
def _db_pool_warnings(pool: dict) -> list[str]:
|
||||||
if pool.get('state') == 'over_budget':
|
if pool.get('state') == 'over_budget':
|
||||||
return [
|
return [
|
||||||
@@ -355,6 +361,8 @@ class HealthView(APIView):
|
|||||||
'traffic': _traffic(),
|
'traffic': _traffic(),
|
||||||
'offsiteBackup': offsite,
|
'offsiteBackup': offsite,
|
||||||
'dbPool': db_pool,
|
'dbPool': db_pool,
|
||||||
|
# جوابهای همزمانِ دستیار در workerی که این را جواب داد (ai_tutor/concurrency.py).
|
||||||
|
'aiConcurrency': _ai_concurrency(),
|
||||||
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
||||||
# IPای که throttle برای همین درخواست میبیند. باید IPِ عمومیِ خودِ
|
# IPای که throttle برای همین درخواست میبیند. باید IPِ عمومیِ خودِ
|
||||||
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
|
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
|
||||||
|
|||||||
@@ -552,7 +552,9 @@ REST_FRAMEWORK = {
|
|||||||
# میتوانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایینتر و
|
# میتوانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایینتر و
|
||||||
# در دیتابیس)؛ اینها فقط جلوی یک ماشینِ تنها را میگیرند.
|
# در دیتابیس)؛ اینها فقط جلوی یک ماشینِ تنها را میگیرند.
|
||||||
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'),
|
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'),
|
||||||
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'),
|
# ۳۰ تا ۲۰۲۶-۱۰-۱۱: یک کلاسِ سینفره روی وایفایِ مدرسه با چند غلطِ تایپی سقف را پر میکرد. حدسِ کد
|
||||||
|
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره میبندد، نه این.
|
||||||
|
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
|
||||||
'bazaar_verify': '10/hour',
|
'bazaar_verify': '10/hour',
|
||||||
'bazaar_oauth': '20/hour',
|
'bazaar_oauth': '20/hour',
|
||||||
'ai_ask': '20/hour',
|
'ai_ask': '20/hour',
|
||||||
@@ -929,6 +931,11 @@ AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
|
|||||||
# تازه میتوانست خرجِ GapGPT را بیسقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
|
# تازه میتوانست خرجِ GapGPT را بیسقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
|
||||||
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
|
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
|
||||||
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
|
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
|
||||||
|
# حداکثر جوابِ همزمانِ دستیار در هر worker (ai_tutor/concurrency.py). هر جواب یک نخ را ۳ تا ۴۰ ثانیه
|
||||||
|
# نگه میدارد؛ بیسقف، رگبارِ سوال هر ۳۲ نخِ worker را میگرفت و نقشه و آزمون پشتش صف میکشیدند. با ۱۲،
|
||||||
|
# دستکم ۲۰ نخ برای بقیهی اپ میماند؛ ۴ worker × ۱۲ = ۴۸ جوابِ همزمان. بیشتر از آن ۵۰۳ «شلوغ است»
|
||||||
|
# میگیرد، پیش از رزروِ سهمیه. سقفِ همزمانیِ خودِ GapGPT را نمیدانیم و نسنجیدهایم.
|
||||||
|
AI_MAX_CONCURRENT_PER_WORKER = int(os.environ.get('AI_MAX_CONCURRENT_PER_WORKER', '12'))
|
||||||
# اختیاری: مدلِ جدا فقط برای پایههای دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
|
# اختیاری: مدلِ جدا فقط برای پایههای دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
|
||||||
# همهی پایهها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده میشود.
|
# همهی پایهها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده میشود.
|
||||||
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ from .models import Lesson, Node, NodeProgress, UserLessonCompletion, UserNodeCo
|
|||||||
|
|
||||||
|
|
||||||
def _get_or_create_progress(user_id, node):
|
def _get_or_create_progress(user_id, node):
|
||||||
"""get_or_create ایجاد میکند، سپس ردیف با select_for_update دوباره خوانده و قفل میشود
|
"""ردیف را با قفل (select_for_update) میگیرد یا میسازد، در یک کوئری برای حالتِ معمول (ردیف هست).
|
||||||
تا خواندن/تغییر/ذخیره در برابر concurrent update ایمن باشد. باید داخل transaction.atomic صدا زده شود."""
|
دو ساختِ همزمان به قیدِ یکتاییِ (user_id, node) میخورند و get_or_create دومی را دوباره میخواند.
|
||||||
NodeProgress.objects.get_or_create(user_id=user_id, node=node)
|
باید داخلِ transaction.atomic صدا زده شود."""
|
||||||
progress = NodeProgress.objects.select_for_update().get(user_id=user_id, node=node)
|
progress, created = NodeProgress.objects.select_for_update().get_or_create(user_id=user_id, node=node)
|
||||||
return progress, False
|
return progress, created
|
||||||
|
|
||||||
|
|
||||||
def _emit_if_changed(progress, old_status, event, user_id):
|
def _emit_if_changed(progress, old_status, event, user_id):
|
||||||
|
|||||||
@@ -200,3 +200,35 @@ class NodeVisualDefaultsTests(TestCase):
|
|||||||
first = self._nodes()[0]
|
first = self._nodes()[0]
|
||||||
self.assertEqual(first['color'], '#123456')
|
self.assertEqual(first['color'], '#123456')
|
||||||
self.assertEqual(first['icon'], 'Trophy')
|
self.assertEqual(first['icon'], 'Trophy')
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMatchesItsSerializerTests(TestCase):
|
||||||
|
"""
|
||||||
|
/api/home برای سرعت dict را مستقیم برمیگرداند (content/views.py، HomeView)، ولی HomeResponseSerializer
|
||||||
|
هنوز قراردادِ Swagger است. اگر کسی کلیدی به پاسخ اضافه کند یا نوعی را عوض کند و سریالساز را نه،
|
||||||
|
این تست میافتد: خروجیِ سریالساز روی همان پاسخ باید عینِ خودش باشد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_view_output_is_exactly_the_documented_shape(self):
|
||||||
|
from accounts.authentication import issue_access_token
|
||||||
|
from content.serializers import HomeResponseSerializer
|
||||||
|
|
||||||
|
cache.clear()
|
||||||
|
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||||
|
for order in (1, 2):
|
||||||
|
unit = Unit.objects.create(grade=grade, title=f'فصل {order}', order=order, blurb='b')
|
||||||
|
for n in (1, 2):
|
||||||
|
node = Node.objects.create(unit=unit, title=f'جزیره {n}', order=n)
|
||||||
|
lesson = Lesson.objects.create(node=node, title='درس')
|
||||||
|
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
|
||||||
|
Choice.objects.create(question=question, value='۱', is_correct=True)
|
||||||
|
user = User.objects.create(phone='09120000088', grade_id=grade.id)
|
||||||
|
UserLessonCompletion.objects.create(
|
||||||
|
user_id=user.id, lesson=Lesson.objects.first(), correct_count=1, stars=3)
|
||||||
|
|
||||||
|
body = self.client.get('/api/home', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}').json()
|
||||||
|
|
||||||
|
self.assertEqual(len(body['units']), 2)
|
||||||
|
self.assertEqual(HomeResponseSerializer(body).data, body)
|
||||||
|
self.assertIsInstance(body['stats']['progressPercent'], float)
|
||||||
|
self.assertIsInstance(body['units'][0]['nodes'][0]['id'], str)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.http import Http404
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -163,9 +164,18 @@ def _record_streak(user_id):
|
|||||||
فعالیتِ امروز را در استریک ثبت میکند. خطایش هرگز نباید ثبتِ پاسخ یا
|
فعالیتِ امروز را در استریک ثبت میکند. خطایش هرگز نباید ثبتِ پاسخ یا
|
||||||
تمام شدنِ درس را خراب کند — استریک تزئینی است، پیشرفت نیست.
|
تمام شدنِ درس را خراب کند — استریک تزئینی است، پیشرفت نیست.
|
||||||
"""
|
"""
|
||||||
|
# استریک روزی یک بار عوض میشود ولی این تابع سرِ هر جواب صدا زده میشود؛ ثبتِ امروز در کشِ Redis
|
||||||
|
# تراکنشِ سهکوئریِ بقیهی جوابهای امروز را حذف میکند. کلید فقط بعد از ثبتِ موفق گذاشته میشود؛
|
||||||
|
# بیRedis (fail-open) هر بار مثلِ قبل به دیتابیس میرود. پاککردنِ دستیِ استریکِ امروزِ کسی از ادمین
|
||||||
|
# تا فردا دوباره ساخته نمیشود.
|
||||||
|
today = timezone.localdate()
|
||||||
|
key = f'streak:recorded:{user_id}:{today.isoformat()}'
|
||||||
|
if cache.get(key):
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
from progress.services import record_activity
|
from progress.services import record_activity
|
||||||
record_activity(user_id)
|
record_activity(user_id, today)
|
||||||
|
cache.set(key, 1, 26 * 3600)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id)
|
logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id)
|
||||||
|
|
||||||
@@ -374,13 +384,14 @@ class HomeView(APIView):
|
|||||||
lid for unit in tree for node in unit['nodes']
|
lid for unit in tree for node in unit['nodes']
|
||||||
for lid in (node.get('playable_ids') or node['lesson_ids'])
|
for lid in (node.get('playable_ids') or node['lesson_ids'])
|
||||||
]
|
]
|
||||||
|
# همهی درسهای تمامشدهی کاربر و بعد اشتراک در پایتون، نه `lesson_id IN (…۶۰۰ شناسه…)`: ساختنِ
|
||||||
|
# آن کوئری در جنگو/psycopg بیشترِ زمانِ CPUِ این صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱)، و ردیفهای یک
|
||||||
|
# کاربر با ایندکسِ user_id خوانده میشوند.
|
||||||
completed_ids = set()
|
completed_ids = set()
|
||||||
if all_lesson_ids:
|
if all_lesson_ids:
|
||||||
completed_ids = set(
|
completed_ids = set(
|
||||||
UserLessonCompletion.objects.filter(
|
UserLessonCompletion.objects.filter(user_id=user_id).values_list('lesson_id', flat=True)
|
||||||
user_id=user_id, lesson_id__in=all_lesson_ids
|
).intersection(all_lesson_ids)
|
||||||
).values_list('lesson_id', flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']]
|
tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']]
|
||||||
quiz_done = quiz_completed_node_ids(user_id, tree_node_ids)
|
quiz_done = quiz_completed_node_ids(user_id, tree_node_ids)
|
||||||
@@ -445,7 +456,7 @@ class HomeView(APIView):
|
|||||||
done_lessons.update(playable_set.intersection(node['lesson_ids']))
|
done_lessons.update(playable_set.intersection(node['lesson_ids']))
|
||||||
progress_percent = (
|
progress_percent = (
|
||||||
round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2)
|
round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2)
|
||||||
if playable_set else 0
|
if playable_set else 0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
stats = UserStats.objects.filter(user_id=user_id).first()
|
stats = UserStats.objects.filter(user_id=user_id).first()
|
||||||
@@ -456,7 +467,10 @@ class HomeView(APIView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = {'units': units_payload, 'stats': stats_payload}
|
data = {'units': units_payload, 'stats': stats_payload}
|
||||||
return Response(HomeResponseSerializer(data).data)
|
# خودِ dict، نه HomeResponseSerializer(data).data: سریالسازِ تو در توی DRF برای ~۳۰ جزیره حدودِ ۱٫۶
|
||||||
|
# میلیثانیه از ~۸٫۶ِ این پرترافیکترین صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱) و چیزی را تبدیل نمیکرد.
|
||||||
|
# سریالساز همچنان قراردادِ Swagger است؛ HomeMatchesItsSerializerTests برابریِ این دو را نگه میدارد.
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
class LessonIntroView(APIView):
|
class LessonIntroView(APIView):
|
||||||
@@ -546,8 +560,12 @@ class QuestionAnswerView(APIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
selected_option_id = serializer.validated_data['selectedOptionId']
|
selected_option_id = serializer.validated_data['selectedOptionId']
|
||||||
|
|
||||||
choice = get_object_or_404(Choice, pk=selected_option_id, question=question)
|
# یک کوئری برای هر چهار گزینه، بهجای یکی برای انتخاب و یکی برای گزینهی درست.
|
||||||
correct_choice = question.choices.filter(is_correct=True).first()
|
choices = list(question.choices.all())
|
||||||
|
choice = next((c for c in choices if c.id == selected_option_id), None)
|
||||||
|
if choice is None:
|
||||||
|
raise Http404
|
||||||
|
correct_choice = next((c for c in choices if c.is_correct), None)
|
||||||
|
|
||||||
user_id = request.user.id
|
user_id = request.user.id
|
||||||
xp_earned = 10 if choice.is_correct else 0
|
xp_earned = 10 if choice.is_correct else 0
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ curl https://mathapi.tadrisino2.ir/api/health
|
|||||||
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
||||||
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: ۴ workerِ `gthread` با ۳۲ نخ و timeoutِ ۱۲۰. تعدادِ workerها همینجاست؛ یونیتِ systemd دیگر `--workers` ندارد (اگر داشته باشد بر این فایل مقدم است) |
|
| `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_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` دیگر پذیرفته نمیشود (برنامه بالا نمیآید) |
|
||||||
|
| `AI_MAX_CONCURRENT_PER_WORKER` | ۱۲: حداکثر جوابِ همزمانِ دستیار در هر worker (۴ × ۱۲ = ۴۸). هر جواب یک نخ را تا ۴۰ ثانیه نگه میدارد؛ سقف نمیگذارد رگبارِ سوال همهی ۳۲ نخ را بگیرد و نقشه و آزمون بخوابند. بیشتر از آن ۵۰۳ «دستیار شلوغه» با `Retry-After` میگیرد، پیش از رزروِ سهمیه. `/api/health` → `aiConcurrency` |
|
||||||
| `DB_STATEMENT_TIMEOUT_MS` | ۳۰٬۰۰۰: هر کوئریِ بیش از ۳۰ ثانیه لغو میشود و تراکنشِ بازِ بیکار بعد از ۶۰ ثانیه بسته میشود. برای فرمانِ مدیریتیِ طولانیِ دستی: `DB_STATEMENT_TIMEOUT_MS=0 python manage.py …` |
|
| `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ِ قدیمی |
|
| `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` |
|
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، بهعلاوهی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر میداند (fail-closed)؛ `/api/health` → `bazaar: false` |
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ def record_activity(user_id, today=None):
|
|||||||
"""امروز را روزِ فعال ثبت میکند و استریک را جلو میبرد. idempotent در یک روز."""
|
"""امروز را روزِ فعال ثبت میکند و استریک را جلو میبرد. idempotent در یک روز."""
|
||||||
today = today or timezone.localdate()
|
today = today or timezone.localdate()
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
StreakRecord.objects.get_or_create(user_id=user_id)
|
streak, _ = StreakRecord.objects.select_for_update().get_or_create(user_id=user_id)
|
||||||
streak = StreakRecord.objects.select_for_update().get(user_id=user_id)
|
|
||||||
|
|
||||||
last = (
|
last = (
|
||||||
ActiveDay.objects.filter(streak=streak)
|
ActiveDay.objects.filter(streak=streak)
|
||||||
|
|||||||
Reference in New Issue
Block a user