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

@@ -17,7 +17,7 @@ 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 . import concurrency, provider_status
from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import (
QuotaResponseSerializer,
@@ -256,6 +256,15 @@ def generate_tutor_answer(
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):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
@@ -282,6 +291,24 @@ class AskAIView(APIView):
tags=['AI Tutor (ai_tutor)'],
)
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.is_valid(raise_exception=True)
v = serializer.validated_data