diff --git a/src/apps/api/idempotency.py b/src/apps/api/idempotency.py new file mode 100644 index 000000000..035f4f0c7 --- /dev/null +++ b/src/apps/api/idempotency.py @@ -0,0 +1,98 @@ +import hashlib +import json +import logging + +from django.core.serializers.json import DjangoJSONEncoder +from django.db import IntegrityError, transaction +from rest_framework import status as http +from rest_framework.response import Response + +from competitions.models import IdempotencyRecord + +logger = logging.getLogger(__name__) + +ENDPOINT_SUBMISSION_CREATE = "submissions.create" + + +def _fingerprint(data): + """Stable sha256 over the request body.""" + try: + canonical = json.dumps(data, sort_keys=True, default=str) + except TypeError: + canonical = repr(data) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _json_safe(data): + """Round-trip through DjangoJSONEncoder so UUID/datetime/Decimal survive.""" + return json.loads(json.dumps(data, cls=DjangoJSONEncoder)) + + +class IdempotentCreateMixin: + """Replay-safe POST keyed by the `Idempotency-Key` header. + + Behaviour: + * No header -> falls back to legacy create() (compat). + * Header + first call -> runs create(), stores response, returns it. + * Header + replay, same payload -> returns stored response (same id). + * Header + replay, diff payload -> 409 Conflict. + * Header + in-flight (race) -> 409 Conflict ("request already in flight"). + + The owner of the key is `request.user`; an anonymous user gets the legacy + path because there is no stable identity to scope the key. + """ + idempotency_endpoint = ENDPOINT_SUBMISSION_CREATE + + def create(self, request, *args, **kwargs): + key = request.headers.get("Idempotency-Key") + if not key or not request.user.is_authenticated: + return super().create(request, *args, **kwargs) + + fp = _fingerprint(request.data) + owner = request.user + endpoint = self.idempotency_endpoint + + try: + with transaction.atomic(): + rec = ( + IdempotencyRecord.objects + .select_for_update() + .filter(owner=owner, endpoint=endpoint, key=key) + .first() + ) + if rec is None: + rec = IdempotencyRecord.objects.create( + owner=owner, + endpoint=endpoint, + key=key, + request_fingerprint=fp, + ) + is_new = True + else: + is_new = False + except IntegrityError: + rec = IdempotencyRecord.objects.get(owner=owner, endpoint=endpoint, key=key) + is_new = False + + if not is_new: + if rec.request_fingerprint != fp: + return Response( + {"detail": "Idempotency-Key reused with a different payload"}, + status=http.HTTP_409_CONFLICT, + ) + if rec.response_status: + return Response(rec.response_body, status=rec.response_status) + return Response( + {"detail": "Request already in flight for this Idempotency-Key"}, + status=http.HTTP_409_CONFLICT, + ) + + resp = super().create(request, *args, **kwargs) + + body = resp.data if isinstance(resp.data, (dict, list)) else {} + rec.response_status = resp.status_code + rec.response_body = _json_safe(body) + if isinstance(resp.data, dict) and "id" in resp.data: + rec.submission_id = resp.data["id"] + rec.save(update_fields=["response_status", "response_body", "submission", "updated_when"]) + return resp diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index bfd6f8889..a3b7bfb0f 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -19,14 +19,15 @@ from api.pagination import DynamicChoicePagination from tasks.models import Task from api.serializers.submissions import SubmissionCreationSerializer, SubmissionSerializer, SubmissionFilesSerializer, SubmissionDetailSerializer -from competitions.models import Submission, SubmissionDetails, Phase, CompetitionParticipant +from api.idempotency import IdempotentCreateMixin, ENDPOINT_SUBMISSION_CREATE +from competitions.models import Submission, SubmissionDetails, Phase, CompetitionParticipant, IdempotencyRecord from leaderboards.strategies import put_on_leaderboard_by_submission_rule from leaderboards.models import SubmissionScore, Column, Leaderboard import logging logger = logging.getLogger(__name__) -class SubmissionViewSet(ModelViewSet): +class SubmissionViewSet(IdempotentCreateMixin, ModelViewSet): queryset = Submission.objects.all().order_by('-pk') permission_classes = [] filter_backends = (DjangoFilterBackend, SearchFilter) @@ -198,6 +199,32 @@ def create(self, request, *args, **kwargs): raise ValidationError('You do not have participant permissions for this group') return super(SubmissionViewSet, self).create(request, *args, **kwargs) + @action(detail=False, methods=['get'], url_path=r'receipt/(?P[^/]+)') + def receipt(self, request, key=None): + """Pollable receipt for a previous POST keyed by Idempotency-Key. + + Returns 404 if no record exists, 202 if the request is in flight, + 200 with the original response body once the create has finalised. + """ + if not request.user.is_authenticated: + raise PermissionDenied('Authentication required') + try: + rec = IdempotencyRecord.objects.get( + owner=request.user, + endpoint=ENDPOINT_SUBMISSION_CREATE, + key=key, + ) + except IdempotencyRecord.DoesNotExist: + return Response({'state': 'unknown'}, status=status.HTTP_404_NOT_FOUND) + if not rec.response_status: + return Response({'state': 'pending'}, status=status.HTTP_202_ACCEPTED) + return Response({ + 'state': 'completed', + 'status': rec.response_status, + 'submission_id': rec.submission_id, + 'body': rec.response_body, + }) + def destroy(self, request, *args, **kwargs): """ - If user is neither owner nor admin, user cannot delete the submission diff --git a/src/apps/competitions/migrations/0062_idempotencyrecord.py b/src/apps/competitions/migrations/0062_idempotencyrecord.py new file mode 100644 index 000000000..0ffb7d209 --- /dev/null +++ b/src/apps/competitions/migrations/0062_idempotencyrecord.py @@ -0,0 +1,67 @@ +# Generated by Django 5.2.14 on 2026-06-17 08:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("competitions", "0061_competition_participant_groups"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="IdempotencyRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.CharField(max_length=128)), + ("endpoint", models.CharField(max_length=64)), + ("request_fingerprint", models.CharField(max_length=64)), + ("response_status", models.PositiveSmallIntegerField(default=0)), + ("response_body", models.JSONField(blank=True, default=dict)), + ("created_when", models.DateTimeField(auto_now_add=True)), + ("updated_when", models.DateTimeField(auto_now=True)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "submission", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="competitions.submission", + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["created_when"], name="competition_created_672c32_idx" + ) + ], + "constraints": [ + models.UniqueConstraint( + fields=["owner", "endpoint", "key"], + name="unique_idempotency_owner_endpoint_key", + ), + ], + }, + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 87b0b5513..caec67707 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -492,6 +492,9 @@ class Submission(models.Model): ingestion_worker_hostname = models.CharField(max_length=255, blank=True, null=True) # Scoring hostname scoring_worker_hostname = models.CharField(max_length=255, blank=True, null=True) + # Incremented every time a worker claims this submission from the broker. + # Used to detect redeliveries (M6) and provide an audit trail. + worker_attempt_count = models.PositiveIntegerField(default=0) queue = models.ForeignKey('queues.Queue', on_delete=models.SET_NULL, null=True, blank=True, related_name='submissions') is_migrated = models.BooleanField(default=False) @@ -809,3 +812,37 @@ class CompetitionWhiteListEmail(models.Model): def __str__(self): return f"{self.email} - Competition: {self.competition.title}" + + +class IdempotencyRecord(models.Model): + """Tracks one client-attempted POST keyed by (owner, endpoint, key). + + Used to make submission creation replay-safe: a client retrying the same + request with the same Idempotency-Key header receives the original response + instead of producing a duplicate row. + """ + PENDING = 0 + key = models.CharField(max_length=128) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + endpoint = models.CharField(max_length=64) + request_fingerprint = models.CharField(max_length=64) + response_status = models.PositiveSmallIntegerField(default=PENDING) + response_body = models.JSONField(default=dict, blank=True) + submission = models.ForeignKey( + 'Submission', null=True, blank=True, + on_delete=models.SET_NULL, related_name='+', + ) + created_when = models.DateTimeField(auto_now_add=True) + updated_when = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['owner', 'endpoint', 'key'], + name='unique_idempotency_owner_endpoint_key', + ), + ] + indexes = [models.Index(fields=['created_when'])] + + def __str__(self): + return f"IdempotencyRecord({self.endpoint}, {self.key}, status={self.response_status})"