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>
288 lines
18 KiB
Python
288 lines
18 KiB
Python
"""
|
||
اجرای یک 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}")
|