Update project

This commit is contained in:
2026-08-19 17:15:55 +03:30
parent 02d40e1238
commit f945f960c9
25 changed files with 1092 additions and 287 deletions

View File

@@ -10,6 +10,7 @@ commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat
"""
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from content.models import NodeProgress
@@ -28,15 +29,46 @@ class Command(BaseCommand):
)
count = 0
for progress in stale_qs:
old_status = progress.status
progress.status = 'abandoned'
progress.save(update_fields=['status'])
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status='abandoned',
event='inactivity_timeout', user_id=progress.user_id,
)
# ⚛️ atomic (به‌ازای هر رکورد، نه کل حلقه): این یک job دوره‌ای رو
# روی هزاران رکورد می‌تونه اجرا کنه. اگه کل حلقه یک atomic واحد
# باشه، یک خطا روی رکورد هزارم، ابطالِ ۹۹۹ رکورد درست‌شده‌ی قبلی رو
# هم rollback می‌کنه (و کل job باید از اول اجرا بشه). به‌جاش هر
# رکورد (save + signal که StatusChangeLog می‌سازه) جدا اتمیکه: یک
# خطای مقطعی فقط همون رکورد رو نصفه‌کاره نگه نمی‌داره، رکوردهای
# قبلی/بعدی مستقل می‌مونن.
#
# ⚛️🔒 اصلاح race condition (lost update): قبلاً همین آبجکتِ
# progressِ خونده‌شده از stale_qs (بدونِ قفل) مستقیماً save می‌شد.
# اگه هم‌زمان یک درخواستِ کاربرِ واقعی (touch_node_progress_on_answer
# یا update_node_progress_on_lesson_completion در content/services.py،
# هر دو با select_for_update) دقیقاً همون ردیف رو به‌روزرسانی و
# last_activity_at رو تازه می‌کرد، این کامند می‌تونست بدونِ اطلاع از
# اون تغییرِ تازه، status رو با مقدارِ قدیمیِ در-حافظه به 'abandoned'
# برگردونه — یعنی فعالیتِ تازه‌ی کاربر توسط این جاب rollback/overwrite
# می‌شد. حالا برای هر رکورد، فقط id از stale_qs گرفته می‌شه؛ ردیفِ
# واقعی داخل transaction.atomic با select_for_update دوباره خونده و
# قفل می‌شه (دقیقاً همون الگویی که مسیرِ کاربر استفاده می‌کنه)، و
# شرطِ نصفه‌رها بودن (status='in_progress' و last_activity_at قدیمی‌تر
# از threshold) بعد از گرفتنِ قفل دوباره چک می‌شه؛ اگه بین queryset
# اولیه و گرفتنِ قفل، یک فعالیتِ کاربر همین ردیف رو تازه کرده باشه،
# دیگه شرط برقرار نیست و این ردیف نادیده گرفته می‌شه (نه abandoned).
stale_ids = list(stale_qs.values_list('id', flat=True))
for progress_id in stale_ids:
with transaction.atomic():
progress = NodeProgress.objects.select_for_update().get(pk=progress_id)
if progress.status != 'in_progress' or progress.last_activity_at is None \
or progress.last_activity_at >= threshold:
continue
old_status = progress.status
progress.status = 'abandoned'
progress.save(update_fields=['status'])
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status='abandoned',
event='inactivity_timeout', user_id=progress.user_id,
)
count += 1
self.stdout.write(self.style.SUCCESS(