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