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:
@@ -15,11 +15,11 @@ from .models import Lesson, Node, NodeProgress, UserLessonCompletion, UserNodeCo
|
||||
|
||||
|
||||
def _get_or_create_progress(user_id, node):
|
||||
"""get_or_create ایجاد میکند، سپس ردیف با select_for_update دوباره خوانده و قفل میشود
|
||||
تا خواندن/تغییر/ذخیره در برابر concurrent update ایمن باشد. باید داخل transaction.atomic صدا زده شود."""
|
||||
NodeProgress.objects.get_or_create(user_id=user_id, node=node)
|
||||
progress = NodeProgress.objects.select_for_update().get(user_id=user_id, node=node)
|
||||
return progress, False
|
||||
"""ردیف را با قفل (select_for_update) میگیرد یا میسازد، در یک کوئری برای حالتِ معمول (ردیف هست).
|
||||
دو ساختِ همزمان به قیدِ یکتاییِ (user_id, node) میخورند و get_or_create دومی را دوباره میخواند.
|
||||
باید داخلِ transaction.atomic صدا زده شود."""
|
||||
progress, created = NodeProgress.objects.select_for_update().get_or_create(user_id=user_id, node=node)
|
||||
return progress, created
|
||||
|
||||
|
||||
def _emit_if_changed(progress, old_status, event, user_id):
|
||||
|
||||
@@ -200,3 +200,35 @@ class NodeVisualDefaultsTests(TestCase):
|
||||
first = self._nodes()[0]
|
||||
self.assertEqual(first['color'], '#123456')
|
||||
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
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
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:
|
||||
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
|
||||
logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id)
|
||||
|
||||
@@ -374,13 +384,14 @@ class HomeView(APIView):
|
||||
lid for unit in tree for node in unit['nodes']
|
||||
for lid in (node.get('playable_ids') or node['lesson_ids'])
|
||||
]
|
||||
# همهی درسهای تمامشدهی کاربر و بعد اشتراک در پایتون، نه `lesson_id IN (…۶۰۰ شناسه…)`: ساختنِ
|
||||
# آن کوئری در جنگو/psycopg بیشترِ زمانِ CPUِ این صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱)، و ردیفهای یک
|
||||
# کاربر با ایندکسِ user_id خوانده میشوند.
|
||||
completed_ids = set()
|
||||
if all_lesson_ids:
|
||||
completed_ids = set(
|
||||
UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson_id__in=all_lesson_ids
|
||||
).values_list('lesson_id', flat=True)
|
||||
)
|
||||
UserLessonCompletion.objects.filter(user_id=user_id).values_list('lesson_id', flat=True)
|
||||
).intersection(all_lesson_ids)
|
||||
|
||||
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)
|
||||
@@ -445,7 +456,7 @@ class HomeView(APIView):
|
||||
done_lessons.update(playable_set.intersection(node['lesson_ids']))
|
||||
progress_percent = (
|
||||
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()
|
||||
@@ -456,7 +467,10 @@ class HomeView(APIView):
|
||||
}
|
||||
|
||||
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):
|
||||
@@ -546,8 +560,12 @@ class QuestionAnswerView(APIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
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
|
||||
xp_earned = 10 if choice.is_correct else 0
|
||||
|
||||
Reference in New Issue
Block a user