Files
MathApp/campaigns/services.py
Zareian 66364e0e92 fix the campaigns concurrency test by fixing what it was catching
ExecuteCampaignRealConcurrencyTests had been written off as a known flake
for weeks — about 45% red on a full-suite run. It was not timing noise.
It was reporting a real defect, and the defect is in production code.

execute_campaign looped with .iterator(), which keeps the read cursor on
the user table open for the whole loop — a loop that writes a
CampaignDelivery per user and, on the sms branch, makes a network call to
Kavenegar in between. On SQLite that open cursor locks the table against
any concurrent writer, and the lock is not released until the loop ends.
So _retry_on_sqlite_lock could never recover from it no matter how long
it waited: widening its window from 0.5s to 2s (also done here, since the
linear backoff was too tight regardless) moved the failure rate not at
all on its own.

Both branches now materialise the ids before the first write. After the
change: 20 consecutive runs of the test itself and 3 full-suite runs, all
green.

This is not only a test fix. While we are on SQLite the same pattern
produces the same lock in production, where it surfaces as "database is
locked" on unrelated requests that happen to land during a campaign run.

Also in this pass
-----------------
- CLAUDE.md carried three sections each claiming to be the current state,
  with three different question counts (1,441 / 1,783 / reality 2,463).
  There is now one current-state section and the older two are labelled
  as history, so whoever picks this up next is not reading stale numbers
  as fact. Three other figures that had drifted are corrected against the
  database: the verdict split, the figure-gap share (881/~42% is now
  508/~14% after the five books were re-read), and the node coverage.
- Dropped four genuinely unused imports. The remaining pyflakes hits are
  the signal registrations in apps.py, which are imported for effect.
- .env.example suggested gpt-4o for vision; gpt-4.1-mini measured both
  cheaper and more accurate and is what the server actually runs.

Audited all 2,463 questions across every grade: no question with the
wrong number of options, none with zero or two correct answers, no empty
or duplicated option, no orphan lesson or node, no duplicate question
inside a lesson.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 04:43:44 +03:30

288 lines
18 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
اجرای یک MessageCampaign: صرفاً «چسباندنِ» سه چیزِ از قبل موجود به هم —
هدف‌گیریِ کاربر (accounts.services)، ارسالِ پیامک (accounts.services.send_sms)
و ثبتِ اعلانِ داخل‌اپ (notifications.services.notify). این ماژول هیچ منطقِ
جدیدِ ارسال/اعلان نمی‌سازه، هیچ صف/جاب پس‌زمینه‌ای نداره، و هیچ زمان‌بندی،
retry یا پیگیریِ تحویلی انجام نمی‌ده — طبق MessageCampaign.__doc__، اونا
مسئولیتِ فیچرهای بعدی هستن.
فراخوانی همیشه sync و بلاکینگ‌ه (برای هر کاربر یک send_sms/notify)؛
مناسبِ لیست‌های کوچیک یا اجرای دستی/تست، نه کمپین‌های بزرگ در پروداکشن.
"""
import time
from django.db import IntegrityError, OperationalError, transaction
from accounts import services as account_services
from accounts.services import SMSDeliveryError
from notifications.services import notify
from .models import CampaignDelivery, MessageCampaign
def _retry_on_sqlite_lock(fn, *, retries=8, base_delay=0.05, max_delay=0.4):
"""
روی SQLite وقتی دو اتصالِ هم‌زمان می‌خوان همون جدول رو بنویسن، ممکنه
OperationalError('database table is locked') بگیریم. این SQLITE_LOCKED‌ه
نه SQLITE_BUSY، پس busy_timeoutِ اتصال کمکی نمی‌کنه و بلافاصله raise
می‌شه؛ خطای واقعیِ داده نیست، فقط تناقضِ لحظه‌ایِ لاک. هر
OperationalError دیگه (یا شکستِ تلاشِ آخر) دست‌نخورده بالا می‌ره.
⚠️ پنجره‌ی انتظار باید از خودِ قفل درازتر باشد، وگرنه محافظ فقط *گاهی*
کار می‌کند. نسخه‌ی اول ۵ تلاش با backoffِ خطی داشت — رویِ‌هم ۰٫۵ ثانیه —
و تستِ هم‌زمانیِ campaigns را در حدودِ نیمی از اجراهای کاملِ سوئیت قرمز
می‌کرد، چون زیرِ بارِ کلِ تست‌ها قفل بیشتر از آن طول می‌کشد. حالا
backoff نمایی است و تا ~۲ ثانیه صبر می‌کند؛ سقفِ max_delay هم هست تا
یک قفلِ طولانی، درخواستِ کاربر را بی‌نهایت معلق نگه ندارد.
"""
for attempt in range(retries):
try:
return fn()
except OperationalError as exc:
if 'locked' not in str(exc).lower() or attempt == retries - 1:
raise
time.sleep(min(base_delay * (2 ** attempt), max_delay))
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
# choices فقط رویدادهاییه که notifications به‌عنوانِ listener گوش می‌ده،
# اینجا notify() مستقیم صدا زده می‌شه؛ Notification.type یک CharField
# معمولیه، مقداردهیِ خارج از choices مجازه.
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'
class UnsupportedTargetFilter(ValueError):
"""target_filter کمپین با هیچ‌کدوم از حالت‌های فعلاً پشتیبانی‌شده مطابقت نداره."""
def _resolve_target_users(target_filter: dict):
"""
target_filter رو به یک QuerySet از accounts.User تبدیل می‌کنه، فقط با
استفاده از هلپرهای موجودِ accounts.services (بدونِ کوئریِ جدید).
حالت‌های فعلاً پشتیبانی‌شده:
{"type": "all"}
{"type": "grade", "grade_id": <int>}
{"type": "inactive_since_days", "days": <int>}
{"type": "active_subscription"}
"""
target_filter = target_filter or {}
filter_type = target_filter.get('type')
if filter_type == 'all':
return account_services.get_all_users()
if filter_type == 'grade':
return account_services.get_users_by_grade(target_filter['grade_id'])
if filter_type == 'inactive_since_days':
from datetime import timedelta
from django.utils import timezone
since = timezone.now() - timedelta(days=target_filter['days'])
return account_services.get_inactive_users(since)
if filter_type == 'active_subscription':
return account_services.get_users_with_active_subscription()
raise UnsupportedTargetFilter(
f"Unsupported target_filter type: {filter_type!r}"
)
def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None) -> None:
"""رکوردِ pending‌ای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت می‌کنه."""
if error is None:
delivery.status = CampaignDelivery.STATUS_SENT
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status']))
return
delivery.status = CampaignDelivery.STATUS_FAILED
delivery.error_message = str(error)[:300]
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status', 'error_message']))
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
"""
رکوردِ pendingِ یک CampaignDelivery رو برای این کاربر «claim» می‌کنه،
یا اگه یک اجرای هم‌زمانِ دیگرِ همین campaign همین کاربر رو زودتر
claim کرده باشه، None برمی‌گردونه (بدونِ ساختنِ رکورد و بدونِ
فراخوانیِ send_sms/notify).
چکِ already_sent_user_ids قبل از شروعِ حلقه یک‌بار انجام می‌شه، پس
بینِ اون چک و رسیدنِ نوبتِ این کاربر در حلقه، ممکنه یک اجرای هم‌زمانِ
دیگر (پراسسِ دیگر/دابل‌کلیکِ اجرا) دقیقاً همین کاربر رو claim کرده و
حتی به sent رسونده باشه (TOCTOU). این تابع همون کاری‌ه که چکِ
اپلیکیشن‌لایه فرض می‌کرد ولی گارانتی نمی‌داد: با تکیه به
CampaignDelivery.Meta.constraints (یک رکوردِ فعال/موفق به ازای هر
campaign+user_id+channel)، اگه یک رکوردِ pending یا sent از قبل
برای همین کاربر وجود داشته باشه (چه توسطِ این اجرا چه توسطِ اجرای
هم‌زمانِ دیگر)، create() با IntegrityError رد می‌شه و این تابع
None برمی‌گردونه — یعنی کاربر به‌جای duplicate delivery، skip می‌شه.
"""
def _create():
with transaction.atomic():
return CampaignDelivery.objects.create(
campaign=campaign, user_id=user_id, channel=campaign.channel,
)
try:
return _retry_on_sqlite_lock(_create)
except IntegrityError:
return None
def _already_sent_user_ids(campaign: MessageCampaign) -> set[int]:
"""
user_id‌هایی که برای همین campaign+channel از قبل یک CampaignDelivery
با status=sent دارن — یعنی تحویلِ واقعی قبلاً با موفقیت انجام شده.
این استعلام دقیقاً همون کاری‌ه که پرامپت به‌عنوانِ جهتِ حل پیشنهاد
داده: «استفاده از رکوردهای CampaignDelivery موجود برای جلوگیری از
ارسالِ دوباره‌ی تحویل‌هایی که قبلاً کامل شدن». عمداً روی pending/failed
فیلتر نمی‌کنه: pending یعنی تلاشِ قبلی هیچ‌وقت به نتیجه نرسیده (مثلاً
اجرا وسط کار قطع شده) و failed یعنی واقعاً تحویل داده نشده — در هر دو
حالت، تلاشِ دوباره «duplicate delivery» نیست، بلکه اولین تحویلِ واقعیِ
موفقه؛ پس این کاربرها باید دوباره امتحان بشن.
"""
return set(
CampaignDelivery.objects.filter(
campaign=campaign,
channel=campaign.channel,
status=CampaignDelivery.STATUS_SENT,
).values_list('user_id', flat=True)
)
def execute_campaign(campaign: MessageCampaign) -> None:
"""
یک MessageCampaign رو اجرا می‌کنه: کاربرانِ هدف رو resolve می‌کنه و به
ازای هر کدوم، بسته به campaign.channel، یا اعلانِ داخل‌اپ ثبت می‌کنه
(notify) یا پیامک می‌فرسته (send_sms). به ازای هر تلاشِ واقعیِ ارسال،
یک CampaignDelivery ساخته می‌شه (ابتدا pending) و بعد از تلاش به
sent/failed آپدیت می‌شه — بدونِ retry، صفِ پس‌زمینه یا زمان‌بندی.
Idempotency: اگه execute_campaign() دوباره برای همون campaign صدا زده
بشه (مثلاً به‌خاطرِ اجرای دستیِ تکراری یا ری‌استارتِ فرآیند وسطِ کار)،
کاربرهایی که از قبل یک CampaignDelivery با status=sent برای همین
campaign+channel دارن، دوباره skip می‌شن — نه CampaignDelivery‌ی
جدیدی براشون ساخته می‌شه و نه send_sms/notify دوباره صداشون می‌زنه.
این چک قبل از شروعِ حلقه، یک‌بار برای کلِ کمپین انجام می‌شه (نه به
ازای هر کاربر) تا کوئریِ اضافه نداشته باشیم. کاربرهایی که رکوردشون
pending یا failed مونده، عمداً دوباره امتحان می‌شن؛ چون براشون هنوز
هیچ تحویلِ موفقی ثبت نشده.
Concurrency: چکِ بالا خودش TOCTOU‌ه (یک‌بار قبل از حلقه محاسبه
می‌شه)، پس اگه دو اجرای execute_campaign() برای همون campaign
هم‌زمان راه بیفتن، ممکنه هر دو یک کاربر رو «هنوز sent نیست» ببینن.
محافظتِ واقعی در _claim_delivery() و در سطحِ دیتابیسه: هر کاربر قبل
از notify/send_sms باید یک رکوردِ CampaignDelivery رو claim کنه، و
یک unique constraint روی مدل (به ازای هر campaign+user_id+channel
حداکثر یک رکوردِ pending/sent) تضمین می‌کنه که فقط یکی از اون دو
اجرای هم‌زمان می‌تونه claim موفق داشته باشه؛ اون یکی دیگه بدونِ
ساختنِ رکورد و بدونِ صدازدنِ notify/send_sms، آن کاربر رو skip
می‌کنه.
کاربرانِ مهمان بدونِ شماره‌تلفن، برای کانالِ sms به‌سادگی رد (skip)
می‌شن و هیچ CampaignDelivery‌ای براشون ساخته نمی‌شه — چون این‌جا
اصلاً تلاشی برای ارسال انجام نشده؛ accounts.services.send_sms برای
شماره‌ی خالی معنی نداره و هیچ صف/ارسالِ جایگزینی هم براشون نیست.
شکستِ تحویلِ sms (SMSDeliveryError) دیگه کلِ اجرای کمپین رو متوقف
نمی‌کنه؛ فقط همون رکورد failed می‌شه و اجرا برای بقیه‌ی کاربران ادامه
پیدا می‌کنه.
Consistency (مارک‌کردنِ sent بعد از تلاشِ موفق): برای in_app، هم
notify() (یک Notification.objects.create ساده) و هم آپدیتِ نهاییِ
CampaignDelivery به sent، هر دو نوشتنِ دیتابیسیِ ساده روی همین
دیتابیسن — پس هر دو تویِ یک `transaction.atomic()` انجام می‌شن. اگه
notify() خودش شکست بخوره یا اگه بعد از notify() موفق، خودِ نوشتنِ
«sent» به هر دلیلی (مثلاً یک خطای دیتابیسِ گذرا) شکست بخوره، کل
تراکنش rollback می‌شه — یعنی Notification‌ای هم که notify() نوشته
بود هیچ‌وقت persist نمی‌شه، و بعدش رکورد به failed آپدیت می‌شه (بیرونِ
همون تراکنشِ rollback‌شده، با یک نوشتنِ مستقلِ جدید) و exception
دوباره بالا می‌ره. نتیجه: هیچ‌وقت به وضعیتِ ناسازگارِ «Notification
واقعاً وجود داره ولی CampaignDelivery رو pendingِ گیرکرده مونده»
نمی‌رسیم؛ رکورد یا واقعاً sent می‌شه (با Notificationِ واقعی) یا
failed می‌شه (بدونِ هیچ Notificationِ persist‌شده‌ای) — و چون failed
از unique constraint مستثناست، برای اجرای بعدی قابلِ retry می‌مونه.
برای sms این کار عمداً انجام نشده و نمی‌تونه انجام بشه: send_sms()
یک تماسِ خارجی با provider‌ه، نه یک نوشتنِ دیتابیسی — یک
transaction.atomic() فقط نوشتن‌های همین دیتابیس رو rollback می‌کنه؛
پیامکی که provider واقعاً فرستاده رو نمی‌شه با rollbackِ دیتابیس
«پس گرفت». اگه بعد از یک send_sms() موفق، خودِ نوشتنِ «sent» شکست
بخوره، exception (بدونِ catch/hide) بالا می‌ره و رکورد pending
می‌مونه — دقیقاً همون رفتارِ قبلی. این pendingِ گیرکرده باعثِ
duplicate SMS در اجرای بعدی نمی‌شه (چون unique constraint یک
claimِ دوباره برای همون کاربر رو رد می‌کنه)، ولی اون یک رکوردِ
خاص هم دیگه هیچ‌وقت خودکار به sent/failed نمی‌رسه — نیازمندِ
بررسیِ دستیه، که پیامدِ ذاتیِ غیرقابل‌اجتنابِ غیرترانزکشنال‌بودنِ
provider خارجیه، نه یک باگِ قابل‌رفعِ کوچیک.
"""
users = _resolve_target_users(campaign.target_filter)
already_sent_user_ids = _already_sent_user_ids(campaign)
if campaign.channel == 'in_app':
# ⚠️ عمداً list() و نه .iterator(): کرسرِ باز باید *پیش از* اولین
# نوشتن بسته شود. با .iterator() خواندنِ کاربرها در تمامِ طولِ حلقه
# باز می‌ماند و روی SQLite همان، جدول را برای هر نویسنده‌ی هم‌زمان
# قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود، پس هیچ مقدار
# retry هم نجاتش نمی‌دهد. این دقیقاً همان چیزی بود که تستِ
# هم‌زمانیِ campaigns را در حدودِ نیمی از اجراها قرمز می‌کرد.
#
# حافظه: اینجا فقط idها نگه داشته می‌شوند و این ماژول طبقِ
# docstringِ بالا برای لیست‌های کوچک است، نه کمپینِ انبوه.
for user_id in list(users.values_list('id', flat=True)):
if user_id in already_sent_user_ids:
continue
delivery = _claim_delivery(campaign, user_id)
if delivery is None:
continue
def _notify_and_mark_sent(user_id=user_id, delivery=delivery):
with transaction.atomic():
notify(
user_id=user_id,
type=CAMPAIGN_NOTIFICATION_TYPE,
title=campaign.title,
body=campaign.body,
related_id=campaign.id,
)
_mark_delivery_result(delivery, error=None)
try:
_retry_on_sqlite_lock(_notify_and_mark_sent)
except Exception as exc:
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
# می‌رسه: تراکنش rollback شده (پس هیچ Notificationِ
# persist‌شده‌ای نمونده)، و این save یک نوشتنِ جدید و
# مستقله که رکورد رو به failed می‌بره تا برای اجرای بعدی
# گیر نکنه. exception عمداً catch/hide نمی‌شه و بالا می‌ره.
_mark_delivery_result(delivery, error=exc)
raise
return
if campaign.channel == 'sms':
# همان دلیلِ شاخه‌ی in_app بالا، و اینجا حتی مهم‌تر: وسطِ این حلقه
# یک فراخوانیِ شبکه‌ای به کاوه‌نگار انجام می‌شود، پس کرسرِ باز
# می‌توانست به‌اندازه‌ی کلِ ارسالِ پیامک‌ها جدول را قفل نگه دارد.
for user_id, phone in list(users.values_list('id', 'phone')):
if user_id in already_sent_user_ids:
continue
if not phone:
continue
delivery = _claim_delivery(campaign, user_id)
if delivery is None:
continue
try:
account_services.send_sms(phone=phone, message=campaign.body)
except SMSDeliveryError as exc:
_mark_delivery_result(delivery, error=exc)
else:
_mark_delivery_result(delivery, error=None)
return
raise ValueError(f"Unsupported campaign channel: {campaign.channel!r}")