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:
2026-10-11 20:55:24 +03:30
parent feaa5eb540
commit 2c708088e4
11 changed files with 279 additions and 19 deletions

View File

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