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:
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()
|
||||
Reference in New Issue
Block a user