fix(events): keep registration status reads nonblocking

This commit is contained in:
2026-10-01 13:35:17 +03:30
parent 962238c32c
commit f924fcc52e
16 changed files with 498 additions and 107 deletions

View File

@@ -1,6 +1,5 @@
"""Registration checkout lifecycle rules shared by API views and workers."""
import logging
from datetime import timedelta
from django.db import transaction
@@ -10,16 +9,6 @@ from apps.events.models import Event, Registration
CHECKOUT_TIMEOUT = timedelta(minutes=15)
logger = logging.getLogger(__name__)
def _dispatch_reconciliation(payment_id):
from apps.payments.tasks import reconcile_payment
try:
reconcile_payment.apply_async(args=(payment_id,), countdown=0)
except Exception:
logger.exception("Unable to dispatch checkout payment %s for reconciliation", payment_id)
def mark_checkout_cancelled(registration, reason):
@@ -38,7 +27,7 @@ def mark_checkout_cancelled(registration, reason):
def queue_checkout_reconciliation(registration):
"""Reconcile each unresolved payment after a checkout is cancelled."""
"""Mark no-authority attempts for review; the recovery sweep dispatches the rest."""
from apps.payments.models import Payment
payments = list(
@@ -51,11 +40,7 @@ def queue_checkout_reconciliation(registration):
).only("id", "authority")
)
for payment in payments:
if payment.authority:
transaction.on_commit(
lambda payment_id=payment.id: _dispatch_reconciliation(payment_id)
)
else:
if not payment.authority:
# The payment request may have reached the gateway before its
# authority was persisted. Keep it visible for review until its
# request response arrives or an administrator resolves it.
@@ -69,7 +54,6 @@ def queue_checkout_reconciliation(registration):
).update(verification_manual_review_at=timezone.now())
@transaction.atomic
def expire_stale_registration_checkouts(*, now=None, event_id=None):
"""Release seats for new-format pending checkouts whose deadline passed.
@@ -85,27 +69,48 @@ def expire_stale_registration_checkouts(*, now=None, event_id=None):
if event_id is not None:
candidates = candidates.filter(event_id=event_id)
expired_count = 0
for registration_id in candidates.values_list("id", flat=True).iterator():
registration = (
Registration.objects.select_for_update()
.filter(pk=registration_id)
.first()
with transaction.atomic():
batch = list(
candidates.select_for_update(skip_locked=True)
.order_by("checkout_expires_at", "pk")[:50]
)
if registration is None or registration.status != Registration.StatusChoices.PENDING:
continue
expired_count = 0
for registration in batch:
if registration.status != Registration.StatusChoices.PENDING or registration.checkout_expires_at > now:
continue
reason = (
Registration.CancellationReasonChoices.EVENT_CANCELLED
if registration.event.status == Event.StatusChoices.CANCELLED
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
)
if mark_checkout_cancelled(registration, reason):
expired_count += 1
queue_checkout_reconciliation(registration)
return expired_count
def expire_user_registration_checkouts(*, event, user, now):
"""Expire only this user's rows while the caller holds the event lock."""
registrations = (
Registration.objects.select_for_update()
.filter(
event=event,
user=user,
is_deleted=False,
status=Registration.StatusChoices.PENDING,
checkout_expires_at__lte=now,
)
.order_by("pk")
)
for registration in registrations:
reason = (
Registration.CancellationReasonChoices.EVENT_CANCELLED
if registration.event.status == Event.StatusChoices.CANCELLED
if event.status == Event.StatusChoices.CANCELLED
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
)
if mark_checkout_cancelled(registration, reason):
expired_count += 1
queue_checkout_reconciliation(registration)
return expired_count
@transaction.atomic
def cancel_event_checkouts(event_id):