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

@@ -39,7 +39,7 @@ from apps.events.models import Event, Registration
from apps.events.checkout import ( from apps.events.checkout import (
CHECKOUT_TIMEOUT, CHECKOUT_TIMEOUT,
cancel_event_checkouts, cancel_event_checkouts,
expire_stale_registration_checkouts, expire_user_registration_checkouts,
mark_checkout_cancelled, mark_checkout_cancelled,
queue_checkout_reconciliation, queue_checkout_reconciliation,
) )
@@ -599,8 +599,13 @@ def register_for_event(
"""Register current user for an event""" """Register current user for an event"""
user = request.auth user = request.auth
now = timezone.now() now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=event_id) with transaction.atomic():
event = get_object_or_404(
Event.objects.select_for_update(),
id=event_id,
is_deleted=False,
)
expire_user_registration_checkouts(event=event, user=user, now=now)
with transaction.atomic(): with transaction.atomic():
event = get_object_or_404( event = get_object_or_404(
Event.objects.select_for_update(), Event.objects.select_for_update(),
@@ -937,10 +942,6 @@ def is_registered(request, event_id: int):
.order_by("-registered_at", "-id") .order_by("-registered_at", "-id")
.first() .first()
) )
if registration and registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at and registration.checkout_expires_at <= now:
expire_stale_registration_checkouts(now=now, event_id=event_id)
registration.refresh_from_db()
payment = None payment = None
if registration: if registration:
payment = registration.payments.order_by("-created_at", "-id").first() payment = registration.payments.order_by("-created_at", "-id").first()
@@ -1154,7 +1155,6 @@ def registration_hub_status(
lookup = {"pk": int(slug)} if slug.isdecimal() else {"slug": slug} lookup = {"pk": int(slug)} if slug.isdecimal() else {"slug": slug}
event = get_object_or_404(Event, is_deleted=False, **lookup) event = get_object_or_404(Event, is_deleted=False, **lookup)
now = timezone.now() now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=event.id)
registration, payment = _registration_hub_records( registration, payment = _registration_hub_records(
event, event,
request.auth, request.auth,

View File

@@ -1,6 +1,5 @@
"""Registration checkout lifecycle rules shared by API views and workers.""" """Registration checkout lifecycle rules shared by API views and workers."""
import logging
from datetime import timedelta from datetime import timedelta
from django.db import transaction from django.db import transaction
@@ -10,16 +9,6 @@ from apps.events.models import Event, Registration
CHECKOUT_TIMEOUT = timedelta(minutes=15) 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): def mark_checkout_cancelled(registration, reason):
@@ -38,7 +27,7 @@ def mark_checkout_cancelled(registration, reason):
def queue_checkout_reconciliation(registration): 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 from apps.payments.models import Payment
payments = list( payments = list(
@@ -51,11 +40,7 @@ def queue_checkout_reconciliation(registration):
).only("id", "authority") ).only("id", "authority")
) )
for payment in payments: for payment in payments:
if payment.authority: if not payment.authority:
transaction.on_commit(
lambda payment_id=payment.id: _dispatch_reconciliation(payment_id)
)
else:
# The payment request may have reached the gateway before its # The payment request may have reached the gateway before its
# authority was persisted. Keep it visible for review until its # authority was persisted. Keep it visible for review until its
# request response arrives or an administrator resolves it. # request response arrives or an administrator resolves it.
@@ -69,7 +54,6 @@ def queue_checkout_reconciliation(registration):
).update(verification_manual_review_at=timezone.now()) ).update(verification_manual_review_at=timezone.now())
@transaction.atomic
def expire_stale_registration_checkouts(*, now=None, event_id=None): def expire_stale_registration_checkouts(*, now=None, event_id=None):
"""Release seats for new-format pending checkouts whose deadline passed. """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: if event_id is not None:
candidates = candidates.filter(event_id=event_id) candidates = candidates.filter(event_id=event_id)
expired_count = 0 with transaction.atomic():
for registration_id in candidates.values_list("id", flat=True).iterator(): batch = list(
registration = ( candidates.select_for_update(skip_locked=True)
Registration.objects.select_for_update() .order_by("checkout_expires_at", "pk")[:50]
.filter(pk=registration_id)
.first()
) )
if registration is None or registration.status != Registration.StatusChoices.PENDING: expired_count = 0
continue 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 = ( reason = (
Registration.CancellationReasonChoices.EVENT_CANCELLED Registration.CancellationReasonChoices.EVENT_CANCELLED
if registration.event.status == Event.StatusChoices.CANCELLED if event.status == Event.StatusChoices.CANCELLED
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
) )
if mark_checkout_cancelled(registration, reason): if mark_checkout_cancelled(registration, reason):
expired_count += 1
queue_checkout_reconciliation(registration) queue_checkout_reconciliation(registration)
return expired_count
@transaction.atomic @transaction.atomic
def cancel_event_checkouts(event_id): def cancel_event_checkouts(event_id):

View File

@@ -0,0 +1,37 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("events", "0016_registration_cancellation_reason_and_more"),
]
operations = [
migrations.CreateModel(
name="RegistrationNotification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("kind", models.CharField(choices=[("confirmed", "Confirmed"), ("cancelled", "Cancelled")], max_length=10)),
("created_at", models.DateTimeField(auto_now_add=True)),
("delivered_at", models.DateTimeField(blank=True, null=True)),
("lease_until", models.DateTimeField(blank=True, null=True)),
(
"registration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_outbox",
to="events.registration",
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("registration", "kind"),
name="unique_registration_notification",
),
],
},
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.5 on 2026-10-01 09:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0017_registrationnotification'),
]
operations = [
migrations.AddIndex(
model_name='registrationnotification',
index=models.Index(condition=models.Q(('delivered_at__isnull', True)), fields=['lease_until', 'created_at'], name='reg_notif_pending_idx'),
),
]

View File

@@ -1,4 +1,4 @@
from django.db import models from django.db import models, transaction
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.utils.text import slugify from django.utils.text import slugify
@@ -201,39 +201,59 @@ class Registration(BaseModel):
"""Human-readable label for the current registration status.""" """Human-readable label for the current registration status."""
return self.get_status_display() return self.get_status_display()
@transaction.atomic
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# detect create vs update is_create = self._state.adding
is_create = self._state.adding old_status = None
old_status = None
if not is_create and self.pk: if not is_create and self.pk:
old_status = ( old_status = self.__class__.objects.only("status").get(pk=self.pk).status
self.__class__.objects.only("status").get(pk=self.pk).status
)
# save first (so we have a pk + final values) super().save(*args, **kwargs)
super().save(*args, **kwargs)
# 1) on create -> send confirmation if pending/confirmed (and not sent before) if (
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_notification_sent_at: self.status == self.StatusChoices.CONFIRMED
# lazy import to avoid circular import and (is_create or old_status != self.StatusChoices.CONFIRMED)
from apps.events.tasks import send_registration_confirmation_notification and not self.confirmation_notification_sent_at
send_registration_confirmation_notification.delay(str(self.pk)) ):
self.confirmation_notification_sent_at = timezone.now() RegistrationNotification.objects.get_or_create(
super().save(update_fields=["confirmation_notification_sent_at"]) registration=self,
# 2) status changed -> cancelled kind=RegistrationNotification.Kind.CONFIRMED,
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_notification_sent_at): )
from apps.events.tasks import send_registration_cancellation_notification if (
send_registration_cancellation_notification.delay(str(self.pk)) not is_create
self.cancellation_notification_sent_at = timezone.now() and old_status != self.StatusChoices.CANCELLED
super().save(update_fields=["cancellation_notification_sent_at"]) and self.status == self.StatusChoices.CANCELLED
and not self.cancellation_notification_sent_at
):
RegistrationNotification.objects.get_or_create(
registration=self,
kind=RegistrationNotification.Kind.CANCELLED,
)
# 3) status changed -> confirmed (if not sent before) from apps.gamification.services import sync_registration_attendance
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_notification_sent_at): sync_registration_attendance(self)
from apps.events.tasks import send_registration_confirmation_notification
send_registration_confirmation_notification.delay(str(self.pk))
self.confirmation_notification_sent_at = timezone.now()
super().save(update_fields=["confirmation_notification_sent_at"])
from apps.gamification.services import sync_registration_attendance
sync_registration_attendance(self) class RegistrationNotification(models.Model):
"""Durable intent to notify after a registration status transition commits."""
class Kind(models.TextChoices):
CONFIRMED = "confirmed", "Confirmed"
CANCELLED = "cancelled", "Cancelled"
registration = models.ForeignKey(Registration, on_delete=models.CASCADE, related_name="notification_outbox")
kind = models.CharField(max_length=10, choices=Kind.choices)
created_at = models.DateTimeField(auto_now_add=True)
delivered_at = models.DateTimeField(null=True, blank=True)
lease_until = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [models.UniqueConstraint(fields=["registration", "kind"], name="unique_registration_notification")]
indexes = [
models.Index(
fields=["lease_until", "created_at"],
name="reg_notif_pending_idx",
condition=models.Q(delivered_at__isnull=True),
),
]

View File

@@ -1,10 +1,14 @@
"""Automatic event registration notifications.""" """Automatic event registration notifications."""
import logging import logging
from datetime import timedelta
from celery import shared_task from celery import shared_task
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from apps.events.models import Registration from apps.events.models import Registration, RegistrationNotification
from apps.events.checkout import expire_stale_registration_checkouts from apps.events.checkout import expire_stale_registration_checkouts
from apps.notifications.services import notify_user from apps.notifications.services import notify_user
from apps.users.tasks import send_critical_sms from apps.users.tasks import send_critical_sms
@@ -19,41 +23,121 @@ def expire_stale_registration_checkouts_task():
@shared_task(bind=True, max_retries=3) @shared_task(bind=True, max_retries=3)
def send_registration_confirmation_notification(self, registration_pk: str): def send_registration_confirmation_notification(self, registration_pk: str, notification_id: str | None = None):
try: try:
registration = Registration.objects.select_related("event", "user").get(pk=registration_pk) result = _send_confirmation(registration_pk, notification_id)
notify_user(registration.user_id, { _mark_notification_delivered(notification_id)
"type": "event_registration", return result
"title": f"ثبت‌نام شما برای «{registration.event.title}» تأیید شد",
"message": "ثبت‌نام شما در این رویداد تأیید شده است.",
"level": "success",
"action_url": f"/events/{registration.event.slug}",
"entity_type": "event",
"entity_id": registration.event_id,
"meta": {"ticket_id": str(registration.ticket_id)},
})
return {"sent": True}
except Exception as exc: except Exception as exc:
logger.error("Failed to notify registration confirmation: %s", exc) logger.error("Failed to notify registration confirmation: %s", exc)
raise self.retry(exc=exc, countdown=60) raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3) @shared_task(bind=True, max_retries=3)
def send_registration_cancellation_notification(self, registration_pk: str): def send_registration_cancellation_notification(self, registration_pk: str, notification_id: str | None = None):
try: try:
registration = Registration.objects.select_related("event", "user").get(pk=registration_pk) result = _send_cancellation(registration_pk, notification_id)
notify_user(registration.user_id, { _mark_notification_delivered(notification_id)
"type": "event_cancellation", return result
"title": f"ثبت‌نام شما برای «{registration.event.title}» لغو شد",
"message": "ثبت‌نام شما در این رویداد لغو شد.",
"level": "warning",
"action_url": f"/events/{registration.event.slug}",
"entity_type": "event",
"entity_id": registration.event_id,
})
if registration.user.mobile and registration.user.is_mobile_verified:
send_critical_sms.delay(registration.user.mobile, "event_cancellation", registration.event.title)
return {"sent": True}
except Exception as exc: except Exception as exc:
logger.error("Failed to notify registration cancellation: %s", exc) logger.error("Failed to notify registration cancellation: %s", exc)
raise self.retry(exc=exc, countdown=60) raise self.retry(exc=exc, countdown=60)
def _send_confirmation(registration_pk, notification_id=None):
registration = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(registration.user_id, {
**({"id": notification_id} if notification_id else {}),
"type": "event_registration",
"title": f"ثبت‌نام شما برای «{registration.event.title}» تأیید شد",
"message": "ثبت‌نام شما در این رویداد تأیید شده است.",
"level": "success",
"action_url": f"/events/{registration.event.slug}",
"entity_type": "event",
"entity_id": registration.event_id,
"meta": {"ticket_id": str(registration.ticket_id)},
})
return {"sent": True}
def _send_cancellation(registration_pk, notification_id=None):
registration = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(registration.user_id, {
**({"id": notification_id} if notification_id else {}),
"type": "event_cancellation",
"title": f"ثبت‌نام شما برای «{registration.event.title}» لغو شد",
"message": "ثبت‌نام شما در این رویداد لغو شد.",
"level": "warning",
"action_url": f"/events/{registration.event.slug}",
"entity_type": "event",
"entity_id": registration.event_id,
})
if registration.user.mobile and registration.user.is_mobile_verified:
send_critical_sms.delay(registration.user.mobile, "event_cancellation", registration.event.title)
return {"sent": True}
def _mark_notification_delivered(notification_id):
if not notification_id or not notification_id.startswith("registration-"):
return
try:
entry_id = int(notification_id.split("-", 2)[1])
except (IndexError, ValueError):
return
delivered_at = timezone.now()
entry = RegistrationNotification.objects.filter(pk=entry_id, delivered_at__isnull=True).first()
if entry is None:
return
entry.delivered_at = delivered_at
entry.lease_until = None
entry.save(update_fields=["delivered_at", "lease_until"])
field = (
"confirmation_notification_sent_at"
if entry.kind == RegistrationNotification.Kind.CONFIRMED
else "cancellation_notification_sent_at"
)
Registration.objects.filter(pk=entry.registration_id, **{f"{field}__isnull": True}).update(
**{field: delivered_at}
)
@shared_task(name="apps.events.deliver_pending_registration_notifications")
def deliver_pending_registration_notifications():
"""Recover notification delivery without involving registration write requests."""
now = timezone.now()
entry_ids = list(
RegistrationNotification.objects.filter(delivered_at__isnull=True)
.filter(Q(lease_until__isnull=True) | Q(lease_until__lte=now))
.order_by("created_at", "pk")
.values_list("pk", flat=True)[:100]
)
delivered = 0
for entry_id in entry_ids:
with transaction.atomic():
entry = RegistrationNotification.objects.select_for_update(skip_locked=True).filter(pk=entry_id).first()
if entry is None or entry.delivered_at or (entry.lease_until and entry.lease_until > now):
continue
entry.lease_until = now + timedelta(minutes=2)
entry.save(update_fields=["lease_until"])
try:
notification_id = f"registration-{entry.pk}-{entry.kind}"
if entry.kind == RegistrationNotification.Kind.CONFIRMED:
_send_confirmation(str(entry.registration_id), notification_id)
sent_field = "confirmation_notification_sent_at"
else:
_send_cancellation(str(entry.registration_id), notification_id)
sent_field = "cancellation_notification_sent_at"
except Exception:
logger.exception("Unable to deliver registration notification %s", entry_id)
RegistrationNotification.objects.filter(pk=entry_id, delivered_at__isnull=True).update(lease_until=None)
continue
delivered_at = timezone.now()
with transaction.atomic():
RegistrationNotification.objects.filter(pk=entry_id, delivered_at__isnull=True).update(
delivered_at=delivered_at, lease_until=None,
)
Registration.objects.filter(pk=entry.registration_id, **{f"{sent_field}__isnull": True}).update(
**{sent_field: delivered_at},
)
delivered += 1
return delivered

View File

@@ -0,0 +1,78 @@
"""PostgreSQL contention checks for the checkout expiry worker and status API."""
import threading
import time
from datetime import timedelta
import pytest
from django.db import connection, connections, transaction
from django.test import Client
from django.utils import timezone
from apps.events.checkout import expire_stale_registration_checkouts
from apps.events.models import Event, Registration
from apps.users.models import User
from core.authentication import create_jwt_token
@pytest.mark.django_db(transaction=True)
@pytest.mark.integration
def test_status_reads_and_expiry_worker_progress_around_a_locked_checkout():
if connection.vendor != "postgresql":
pytest.skip("PostgreSQL row locks are required")
now = timezone.now()
event = Event.objects.create(
title="Checkout lock test", slug="checkout-lock-test", description="Lock test",
start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1),
registration_start_date=now - timedelta(days=1),
registration_end_date=now + timedelta(days=1),
status=Event.StatusChoices.PUBLISHED, price=1000, capacity=2,
)
user = User.objects.create_user(username="checkout-lock-owner", mobile="09191112233", password="TestPass123!")
other = User.objects.create_user(username="checkout-lock-other", mobile="09191112234", password="TestPass123!")
locked = Registration.objects.create(
event=event, user=user, status=Registration.StatusChoices.PENDING,
checkout_expires_at=now - timedelta(minutes=2),
)
free = Registration.objects.create(
event=event, user=other, status=Registration.StatusChoices.PENDING,
checkout_expires_at=now - timedelta(minutes=1),
)
acquired = threading.Event()
release = threading.Event()
def hold_row_lock():
try:
with transaction.atomic():
Registration.objects.select_for_update().get(pk=locked.pk)
acquired.set()
release.wait(10)
finally:
connections.close_all()
holder = threading.Thread(target=hold_row_lock)
holder.start()
try:
assert acquired.wait(5)
start = time.monotonic()
response = Client().get(
f"/api/events/{event.slug}/registration/status",
HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(user)}",
)
assert response.status_code == 200
assert time.monotonic() - start < 2
assert expire_stale_registration_checkouts(event_id=event.id) == 1
locked.refresh_from_db()
free.refresh_from_db()
assert locked.status == Registration.StatusChoices.PENDING
assert free.status == Registration.StatusChoices.CANCELLED
finally:
release.set()
holder.join(timeout=5)
assert not holder.is_alive()
assert expire_stale_registration_checkouts(event_id=event.id) == 1
locked.refresh_from_db()
assert locked.status == Registration.StatusChoices.CANCELLED

View File

@@ -4,8 +4,9 @@ from datetime import timedelta
from django.contrib import admin from django.contrib import admin
from django.utils import timezone from django.utils import timezone
from apps.events.admin import EventAdmin, RegistrationAdmin from apps.events.admin import EventAdmin, RegistrationAdmin
from apps.events.models import Event, Registration from apps.events.models import Event, Registration, RegistrationNotification
from apps.events.tasks import send_registration_cancellation_notification, send_registration_confirmation_notification from apps.events.tasks import send_registration_cancellation_notification, send_registration_confirmation_notification
from apps.events.tasks import deliver_pending_registration_notifications
from apps.users.models import User from apps.users.models import User
@pytest.mark.django_db @pytest.mark.django_db
@@ -25,10 +26,33 @@ class TestEventModelTests:
def test_capacity_counts_confirmed_registration(self, mocker): def test_capacity_counts_confirmed_registration(self, mocker):
delay = mocker.patch('apps.events.tasks.send_registration_confirmation_notification.delay') delay = mocker.patch('apps.events.tasks.send_registration_confirmation_notification.delay')
Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED) registration = Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED)
assert self.event.current_attendees_count == 1 assert self.event.current_attendees_count == 1
assert not self.event.has_available_slots assert not self.event.has_available_slots
delay.assert_called_once() assert RegistrationNotification.objects.filter(
registration=registration, kind=RegistrationNotification.Kind.CONFIRMED,
).exists()
delay.assert_not_called()
def test_notification_worker_retries_after_redis_failure(self, mocker):
registration = Registration.objects.create(
event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED,
)
entry = RegistrationNotification.objects.get(registration=registration)
notify = mocker.patch("apps.events.tasks.notify_user", side_effect=ConnectionError("redis unavailable"))
assert deliver_pending_registration_notifications() == 0
entry.refresh_from_db()
assert entry.delivered_at is None
notify.side_effect = None
assert deliver_pending_registration_notifications() == 1
entry.refresh_from_db()
registration.refresh_from_db()
assert entry.delivered_at is not None
assert registration.confirmation_notification_sent_at is not None
assert notify.call_args.args[1]["id"] == f"registration-{entry.pk}-confirmed"
assert deliver_pending_registration_notifications() == 0
def test_unlimited_capacity_allows_registration(self): def test_unlimited_capacity_allows_registration(self):
self.event.capacity = None self.event.capacity = None

View File

@@ -6,7 +6,7 @@ import pytest
from django.test import Client from django.test import Client
from django.utils import timezone from django.utils import timezone
from apps.events.models import Event, Registration from apps.events.models import Event, Registration, RegistrationNotification
from apps.events.checkout import cancel_event_checkouts, expire_stale_registration_checkouts from apps.events.checkout import cancel_event_checkouts, expire_stale_registration_checkouts
from apps.payments.models import Payment from apps.payments.models import Payment
from apps.users.models import User from apps.users.models import User
@@ -142,6 +142,23 @@ class TestRegistrationCheckout:
assert payload["payment"] is None assert payload["payment"] is None
assert payload["can_register"] is True assert payload["can_register"] is True
def test_status_reads_do_not_cancel_an_expired_checkout_when_notifications_are_unavailable(self, mocker):
registration, _ = self.create_pending_checkout(expires_at=timezone.now() - timedelta(seconds=1))
mocker.patch(
"apps.events.tasks.send_registration_cancellation_notification.delay",
side_effect=RuntimeError("broker unavailable"),
)
hub = self.client.get(f"/api/events/{self.event.slug}/registration/status", **self.auth_headers())
legacy = self.client.get(f"/api/events/{self.event.id}/is-registered", **self.auth_headers())
registration.refresh_from_db()
assert hub.status_code == 200
assert hub.json()["state"] == "verification_pending"
assert legacy.status_code == 200
assert registration.status == Registration.StatusChoices.PENDING
assert not RegistrationNotification.objects.filter(registration=registration).exists()
def test_registration_hub_requires_authentication(self): def test_registration_hub_requires_authentication(self):
response = self.client.get(f"/api/events/{self.event.slug}/registration/status") response = self.client.get(f"/api/events/{self.event.slug}/registration/status")
assert response.status_code == 401 assert response.status_code == 401
@@ -376,6 +393,30 @@ class TestRegistrationCheckout:
assert payment.status == Payment.OrderStatusChoices.PENDING assert payment.status == Payment.OrderStatusChoices.PENDING
assert Payment.objects.filter(event=self.event, user=self.user).count() == 1 assert Payment.objects.filter(event=self.event, user=self.user).count() == 1
def test_payment_creation_rechecks_window_after_expiring_checkout_without_payment(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
checkout_expires_at=timezone.now() - timedelta(seconds=1),
final_price=self.event.price,
)
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
self.event.save(update_fields=["registration_end_date"])
response = self.client.post(
"/api/payments/create",
data=json.dumps({"event_id": self.event.id, "description": "Expired checkout",
"idempotency_key": str(uuid.uuid4())}),
content_type="application/json",
**self.auth_headers(),
)
registration.refresh_from_db()
assert response.status_code == 400
assert registration.status == Registration.StatusChoices.CANCELLED
assert not Payment.objects.filter(registration=registration).exists()
def test_expiry_sweep_cancels_only_new_expired_checkouts_and_releases_the_seat(self): def test_expiry_sweep_cancels_only_new_expired_checkouts_and_releases_the_seat(self):
expired_at = timezone.now() - timedelta(seconds=1) expired_at = timezone.now() - timedelta(seconds=1)
registration, payment = self.create_pending_checkout(expires_at=expired_at) registration, payment = self.create_pending_checkout(expires_at=expired_at)
@@ -463,10 +504,11 @@ class TestRegistrationCheckout:
assert payment_response.status_code == 409 assert payment_response.status_code == 409
assert Registration.objects.filter(event=self.event, user=self.user).count() == 1 assert Registration.objects.filter(event=self.event, user=self.user).count() == 1
def test_event_cancellation_ends_checkouts_and_queues_payment_reconciliation(self, mocker): def test_event_cancellation_ends_checkouts_and_recovers_payment_reconciliation(self, mocker):
registration, payment = self.create_pending_checkout() registration, payment = self.create_pending_checkout()
from apps.payments.tasks import sweep_stale_payment_verifications
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async") enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
mocker.patch("apps.events.checkout.transaction.on_commit", side_effect=lambda callback: callback())
self.event.status = Event.StatusChoices.CANCELLED self.event.status = Event.StatusChoices.CANCELLED
self.event.save(update_fields=["status"]) self.event.save(update_fields=["status"])
@@ -477,4 +519,9 @@ class TestRegistrationCheckout:
assert registration.status == Registration.StatusChoices.CANCELLED assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.EVENT_CANCELLED assert registration.cancellation_reason == Registration.CancellationReasonChoices.EVENT_CANCELLED
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0 assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
enqueue.assert_called_once_with(args=(payment.id,), countdown=0) assert RegistrationNotification.objects.filter(
registration=registration,
kind=RegistrationNotification.Kind.CANCELLED,
).exists()
assert sweep_stale_payment_verifications() == 1
enqueue.assert_called_once_with(args=(payment.id,))

View File

@@ -88,6 +88,10 @@ class RedisNotificationStore:
@classmethod @classmethod
def add(cls, user_id: str, payload: dict) -> dict: def add(cls, user_id: str, payload: dict) -> dict:
if payload.get("id"):
existing = cls.get(user_id, str(payload["id"]))
if existing:
return existing
data = cls._normalize_notification(payload) data = cls._normalize_notification(payload)
created_at = parse_datetime(data["created_at"]) or timezone.now() created_at = parse_datetime(data["created_at"]) or timezone.now()
created_at_ts = created_at.timestamp() created_at_ts = created_at.timestamp()

View File

@@ -18,7 +18,7 @@ from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
from apps.events.checkout import ( from apps.events.checkout import (
CHECKOUT_TIMEOUT, CHECKOUT_TIMEOUT,
expire_stale_registration_checkouts, expire_user_registration_checkouts,
queue_checkout_reconciliation, queue_checkout_reconciliation,
) )
from apps.notifications.services import notify_user from apps.notifications.services import notify_user
@@ -316,8 +316,13 @@ def _ensure_checkout_attempt_can_be_reused(payment: Payment, *, now, allow_revie
def create_payment(request, payload: CreatePaymentIn): def create_payment(request, payload: CreatePaymentIn):
idempotency_key = payload.idempotency_key or uuid4() idempotency_key = payload.idempotency_key or uuid4()
now = timezone.now() now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=payload.event_id) with transaction.atomic():
event = get_object_or_404(
Event.objects.select_for_update(),
pk=payload.event_id,
is_deleted=False,
)
expire_user_registration_checkouts(event=event, user=request.auth, now=now)
# Serialize checkout creation for this event so concurrent taps cannot # Serialize checkout creation for this event so concurrent taps cannot
# both create a new active attempt for the same user/event pair. # both create a new active attempt for the same user/event pair.
with transaction.atomic(): with transaction.atomic():

18
config/api_errors.py Normal file
View File

@@ -0,0 +1,18 @@
"""HTTP handling for recoverable database errors."""
from django.http import JsonResponse
def handle_database_operational_error(request, error):
"""Return a retryable response when PostgreSQL rejects a lock wait."""
cause = error.__cause__
while cause is not None:
if getattr(cause, "sqlstate", None) == "55P03" or getattr(cause, "pgcode", None) == "55P03":
response = JsonResponse(
{"detail": "درخواست به‌دلیل شلوغی موقت پایگاه داده انجام نشد؛ کمی بعد دوباره تلاش کنید."},
status=503,
)
response["Retry-After"] = "2"
return response
cause = cause.__cause__
raise error

View File

@@ -48,6 +48,11 @@ app.conf.beat_schedule = {
'schedule': crontab(minute='*'), 'schedule': crontab(minute='*'),
'description': 'Releases seats from paid checkouts after their 15-minute window.', 'description': 'Releases seats from paid checkouts after their 15-minute window.',
}, },
'deliver-registration-notifications': {
'task': 'apps.events.deliver_pending_registration_notifications',
'schedule': crontab(minute='*'),
'description': 'Retries committed registration notifications.',
},
'sweep-pending-refunds': { 'sweep-pending-refunds': {
'task': 'apps.payments.sweep_pending_refunds', 'task': 'apps.payments.sweep_pending_refunds',
'schedule': crontab(minute='*/5'), 'schedule': crontab(minute='*/5'),

View File

@@ -249,6 +249,15 @@ BLOG_ASSET_MAX_SIZE_MB = config('BLOG_ASSET_MAX_SIZE_MB', default=50, cast=int)
BLOG_IMAGE_ASSET_MAX_SIZE_MB = config('BLOG_IMAGE_ASSET_MAX_SIZE_MB', default=10, cast=int) BLOG_IMAGE_ASSET_MAX_SIZE_MB = config('BLOG_IMAGE_ASSET_MAX_SIZE_MB', default=10, cast=int)
if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql": if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql":
lock_timeout_ms = config("DB_LOCK_TIMEOUT_MS", default=5000, cast=int)
idle_transaction_timeout_ms = config("DB_IDLE_TRANSACTION_TIMEOUT_MS", default=60000, cast=int)
database_options = DATABASES["default"].setdefault("OPTIONS", {})
existing_options = database_options.get("options") or ""
timeout_options = (
f"-c lock_timeout={lock_timeout_ms}ms "
f"-c idle_in_transaction_session_timeout={idle_transaction_timeout_ms}ms"
)
database_options["options"] = f"{existing_options} {timeout_options}".strip()
DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql" DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql"
from config.services.unfold import * from config.services.unfold import *

View File

@@ -0,0 +1,35 @@
import json
import pytest
from django.db import OperationalError
from config.api_errors import handle_database_operational_error
pytestmark = pytest.mark.unit
class PostgresError(Exception):
def __init__(self, sqlstate):
super().__init__(sqlstate)
self.sqlstate = sqlstate
def test_database_lock_timeout_returns_retryable_service_unavailable():
error = OperationalError("canceling statement due to lock timeout")
error.__cause__ = PostgresError("55P03")
response = handle_database_operational_error(None, error)
assert response.status_code == 503
assert response["Retry-After"] == "2"
assert json.loads(response.content) == {
"detail": "درخواست به‌دلیل شلوغی موقت پایگاه داده انجام نشد؛ کمی بعد دوباره تلاش کنید."
}
def test_unrelated_database_operational_error_is_not_hidden():
error = OperationalError("database unavailable")
error.__cause__ = PostgresError("08006")
with pytest.raises(OperationalError, match="database unavailable"):
handle_database_operational_error(None, error)

View File

@@ -1,16 +1,19 @@
from django.contrib import admin from django.contrib import admin
from django.db import OperationalError
from django.urls import path, include from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from ninja import NinjaAPI from ninja import NinjaAPI
from apps.notifications.api.views import NotificationStreamView from apps.notifications.api.views import NotificationStreamView
from config.api import router as api_router from config.api import router as api_router
from config.api_errors import handle_database_operational_error
api = NinjaAPI( api = NinjaAPI(
title="CS Association API", title="CS Association API",
version="1.0.0", version="1.0.0",
description="API for University Computer Science Association", description="API for University Computer Science Association",
) )
api.add_exception_handler(OperationalError, handle_database_operational_error)
api.add_router("", api_router) api.add_router("", api_router)