tests: two questions at once with one credit, with real threads

The claim that the user-row lock plus SQLite IMMEDIATE stops one credit
being spent twice was untested. Two threads on separate connections now
ask at the same time: one answer, one credit spent; the same
Idempotency-Key arriving twice at once also costs one credit. Removing
transaction_mode IMMEDIATE turns both red (500, database is locked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:07:08 +03:30
parent fe658c697a
commit 67cbe05609
3 changed files with 77 additions and 4 deletions

View File

@@ -0,0 +1,73 @@
"""
اعتبارِ بسته زیرِ درخواستِ هم‌زمان (بازبینیِ ۲۰۲۶-۱۰-۰۲).
پیش از این فقط ادعا بود که قفلِ ردیفِ کاربر و `transaction_mode: IMMEDIATE`ِ SQLite جلوی
خرجِ دوباره‌ی یک اعتبار را می‌گیرند. این‌جا دو thread واقعی، با دو اتصالِ جدا به دیتابیسِ فایلیِ
تست، هم‌زمان می‌پرسند. جوابِ دستیار ساختگی است و ۰٫۴ ثانیه طول می‌کشد تا دو درخواست واقعاً روی
هم بیفتند. فقط روی SQLite سنجیده شده؛ بعد از مهاجرت به Postgres دوباره باید سبز شود.
"""
import threading
import time
from unittest.mock import patch
from django.core.cache import cache
from django.db import connection
from django.test import TransactionTestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from subscription.models import QuestionCredit
from subscription.services import credit_balance
from .models import Message
def _slow_answer(*args, **kwargs):
time.sleep(0.4)
return 'جواب', ['بیشتر توضیح بده']
@override_settings(AI_DAILY_QUOTA_FREE=0)
class ConcurrentCreditTests(TransactionTestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007201', is_guest=False)
def tearDown(self):
cache.clear()
def race(self, *headers):
codes = []
barrier = threading.Barrier(len(headers))
def ask(extra):
try:
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
barrier.wait()
codes.append(client.post('/api/ai/ask', {'userQuestion': '۲ + ۲؟'}, format='json', **extra).status_code)
finally:
connection.close()
with patch('rag.services.config.rag_is_configured', return_value=False), \
patch('ai_tutor.views.generate_tutor_answer', side_effect=_slow_answer):
threads = [threading.Thread(target=ask, args=(h,)) for h in headers]
for t in threads:
t.start()
for t in threads:
t.join()
return sorted(codes)
def test_two_questions_at_once_with_one_credit_get_one_answer(self):
QuestionCredit.objects.create(user_id=self.user.id, delta=1, reason='grant')
self.assertEqual(self.race({}, {}), [200, 429])
self.assertEqual(credit_balance(self.user.id), 0)
self.assertEqual(Message.objects.filter(role='assistant').count(), 1)
def test_the_same_idempotency_key_twice_at_once_costs_one_credit(self):
QuestionCredit.objects.create(user_id=self.user.id, delta=2, reason='grant')
key = {'HTTP_IDEMPOTENCY_KEY': 'same-key'}
self.assertEqual(self.race(key, key), [200, 200])
self.assertEqual(credit_balance(self.user.id), 1)
self.assertEqual(Message.objects.filter(role='user').count(), 1)

View File

@@ -57,8 +57,9 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa
سوال از کجا پرداخت شد (`free` / `credit` / `none`). سوال از کجا پرداخت شد (`free` / `credit` / `none`).
- **دفترِ اعتبار** `subscription.QuestionCredit`: هر خرید یک ردیفِ مثبت دارد (هر سفارش فقط یک بار، با - **دفترِ اعتبار** `subscription.QuestionCredit`: هر خرید یک ردیفِ مثبت دارد (هر سفارش فقط یک بار، با
قیدِ یکتا) و هر سوال یک ردیفِ ۱−. کم شدن داخلِ همان تراکنشی است که ردیفِ کاربر را قفل می‌کند. دو قیدِ یکتا) و هر سوال یک ردیفِ ۱−. کم شدن داخلِ همان تراکنشی است که ردیفِ کاربر را قفل می‌کند. دو
سوالِ هم‌زمان با یک اعتبار آزموده نشده است؛ جلوگیری به آن قفل و `transaction_mode: IMMEDIATE`ِ سوالِ هم‌زمان با یک اعتبار، و یک Idempotency-Key که هم‌زمان دو بار برسد، از ۲۰۲۶-۱۰-۰۲ با دو thread
SQLite سپرده شده. واقعی آزموده می‌شوند (`ai_tutor/tests_credit_concurrency.py`): یک جواب و یک اعتبار. بدونِ
`transaction_mode: IMMEDIATE`ِ SQLite همین تست ۵۰۰ می‌گیرد. روی Postgres سنجیده نشده.
- **قلبِ نامحدودِ بسته** در `HeartsPass` است، جدا از `Subscription`، تا خریدِ بسته اشتراکِ کسی را عوض - **قلبِ نامحدودِ بسته** در `HeartsPass` است، جدا از `Subscription`، تا خریدِ بسته اشتراکِ کسی را عوض
نکند. از پایانِ کارتِ قبلی ادامه پیدا می‌کند. `HEARTS_ENFORCED` حالا پیش‌فرض روشن است، پس قلبِ صفر نکند. از پایانِ کارتِ قبلی ادامه پیدا می‌کند. `HEARTS_ENFORCED` حالا پیش‌فرض روشن است، پس قلبِ صفر
واقعاً جلوی جواب را می‌گیرد. واقعاً جلوی جواب را می‌گیرد.

View File

@@ -3,8 +3,7 @@
راه‌های شکستن که امتحان می‌شوند: اعتبارِ دوباره برای تأییدِ تکراریِ پرداخت، عوض شدنِ اشتراکِ فعالِ راه‌های شکستن که امتحان می‌شوند: اعتبارِ دوباره برای تأییدِ تکراریِ پرداخت، عوض شدنِ اشتراکِ فعالِ
کسی که بسته می‌خرد، خرج شدنِ اعتبار برای سوالی که به API نرسید یا جوابش ساخته نشد، و قلبِ هدیه که کسی که بسته می‌خرد، خرج شدنِ اعتبار برای سوالی که به API نرسید یا جوابش ساخته نشد، و قلبِ هدیه که
در سرور اثری نداشته باشد. هم‌زمانیِ دو سوال با یک اعتبار این‌جا آزموده نشده؛ جلوگیری‌اش به قفلِ در سرور اثری نداشته باشد. هم‌زمانیِ دو سوال با یک اعتبار در ai_tutor/tests_credit_concurrency.py است.
ردیفِ کاربر و IMMEDIATEِ SQLite سپرده شده (ai_tutor.views، فازِ ۱).
""" """
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import patch