- /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>
77 lines
3.2 KiB
Python
77 lines
3.2 KiB
Python
"""
|
||
سقفِ جوابهای همزمانِ دستیار در هر worker (AI_MAX_CONCURRENT_PER_WORKER).
|
||
|
||
جوابِ دستیار یک نخِ gunicorn را تا پایانِ کار (۳ تا ۴۰ ثانیه، منتظرِ Qdrant و مدل) نگه میدارد. بدونِ
|
||
سقف، اگر چند ده دانشآموز با هم سوال بپرسند همهی نخهای worker گرفته میشوند و نقشه، آزمون و ثبتِ
|
||
جواب (که هرکدام چند میلیثانیهاند) پشتِ آنها صف میکشند: یک بخشِ کند کلِ اپ را میخواباند. این همان
|
||
الگوی bulkhead است: هر بخش سهمِ جدای خودش را دارد. درخواستی که جا نگیرد فوراً ۵۰۳ میگیرد.
|
||
|
||
سمافور مالِ همین پروسه است (هر worker جدا). همزمانیِ واقعی زیرِ بارِ gunicorn با مدلِ واقعی آزموده نشده؛
|
||
تستها رفتارِ گرفتن/پسدادن را در مسیرِ JSON و استریم نگه میدارند (ai_tutor/tests_concurrency.py).
|
||
"""
|
||
import threading
|
||
|
||
from django.conf import settings
|
||
|
||
_lock = threading.Lock()
|
||
_in_flight = 0
|
||
|
||
|
||
def limit() -> int:
|
||
return settings.AI_MAX_CONCURRENT_PER_WORKER
|
||
|
||
|
||
def try_acquire() -> bool:
|
||
"""بیانتظار: یا جا هست و گرفته میشود، یا False. انتظار نخ را همانطور نگه میداشت که میخواهیم نشود."""
|
||
global _in_flight
|
||
with _lock:
|
||
if _in_flight >= limit():
|
||
return False
|
||
_in_flight += 1
|
||
return True
|
||
|
||
|
||
def release() -> None:
|
||
global _in_flight
|
||
with _lock:
|
||
if _in_flight <= 0:
|
||
# پسدادنِ بیگرفتن یعنی باگ در جفتشدنِ گرفتن/پسدادن؛ شمارنده منفی نمیشود تا سقف بیاثر نشود.
|
||
raise RuntimeError('ai concurrency slot released more times than acquired')
|
||
_in_flight -= 1
|
||
|
||
|
||
def in_flight() -> int:
|
||
return _in_flight
|
||
|
||
|
||
class ReleaseWhenDone:
|
||
"""
|
||
محتوای استریم را میپیچد تا جا وقتی پس داده شود که استریم واقعاً تمام شد: یا با پایانِ خواندن،
|
||
یا با close() که جنگو هنگامِ بستنِ پاسخ صدا میزند (کاربر وسطِ جواب صفحه را بست، یا پاسخ اصلاً
|
||
خوانده نشد). generatorی که شروع نشده با close() بلوکِ finallyاش را اجرا نمیکند؛ برای همین
|
||
پسدادن اینجاست، نه داخلِ generator.
|
||
"""
|
||
|
||
def __init__(self, iterable):
|
||
self._iterable = iterable
|
||
self._released = False
|
||
|
||
def _release_once(self):
|
||
if not self._released:
|
||
self._released = True
|
||
release()
|
||
|
||
def __iter__(self):
|
||
try:
|
||
yield from self._iterable
|
||
finally:
|
||
self._release_once()
|
||
|
||
def close(self):
|
||
try:
|
||
close = getattr(self._iterable, 'close', None)
|
||
if close is not None:
|
||
close()
|
||
finally:
|
||
self._release_once()
|