From 108bf0e85a765c569c505f564f78eb4dcc21cf01 Mon Sep 17 00:00:00 2001 From: Akanshajain231999 Date: Tue, 28 Apr 2026 13:49:09 -0700 Subject: [PATCH 1/2] Backend: Cache leaderboard endpoint responses to reduce slow DB query Wraps the /api/jobs/challenge_phase_split/{id}/leaderboard/ view with a 30-second Memcached lookup keyed by phase split, order_by, and a host bit. The underlying SQL joins four tables and applies LIMIT 10000 for correctness (post-query Python re-sorts and dedups by team), so pagination cannot be pushed into the database; caching is the right lever for repeat hits within a short window. The composite index aligned with the WHERE+ORDER BY pattern already exists from migration 0116. 400 responses are not cached. Co-Authored-By: Claude Opus 4.7 --- apps/jobs/views.py | 35 ++++++++--- tests/unit/jobs/test_views.py | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/apps/jobs/views.py b/apps/jobs/views.py index 8d7eb43c90..c2557e66e1 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -31,6 +31,7 @@ get_participant_model, ) from django.conf import settings +from django.core.cache import cache from django.core.files.base import ContentFile from django.core.files.uploadedfile import SimpleUploadedFile from django.db import IntegrityError, transaction @@ -639,16 +640,32 @@ def leaderboard(request, challenge_phase_split_id): ) challenge_obj = challenge_phase_split.challenge_phase.challenge order_by = request.GET.get("order_by") - ( - response_data, - http_status_code, - ) = calculate_distinct_sorted_leaderboard_data( - request.user, - challenge_obj, - challenge_phase_split, - only_public_entries=True, - order_by=order_by, + + is_host_bit = int( + bool(is_user_a_staff_or_host(request.user, challenge_obj.pk)) + ) + cache_key = "leaderboard:v1:{phase_split}:{order_by}:{is_host}".format( + phase_split=challenge_phase_split_id, + order_by=order_by or "default", + is_host=is_host_bit, ) + cached = cache.get(cache_key) + if cached is not None: + response_data, http_status_code = cached + else: + ( + response_data, + http_status_code, + ) = calculate_distinct_sorted_leaderboard_data( + request.user, + challenge_obj, + challenge_phase_split, + only_public_entries=True, + order_by=order_by, + ) + if http_status_code == status.HTTP_200_OK: + cache.set(cache_key, (response_data, http_status_code), 30) + # The response 400 will be returned if the leaderboard isn't public or # `default_order_by` key is missing in leaderboard. if http_status_code == status.HTTP_400_BAD_REQUEST: diff --git a/tests/unit/jobs/test_views.py b/tests/unit/jobs/test_views.py index a31a727441..3c41b2dc25 100644 --- a/tests/unit/jobs/test_views.py +++ b/tests/unit/jobs/test_views.py @@ -17,11 +17,14 @@ LeaderboardData, ) from django.contrib.auth.models import User +from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings from django.urls import reverse_lazy from django.utils import timezone from hosts.models import ChallengeHost, ChallengeHostTeam from jobs.models import Submission +from jobs.utils import calculate_distinct_sorted_leaderboard_data from moto import mock_s3 from participants.models import Participant, ParticipantTeam from rest_framework import status @@ -2404,6 +2407,109 @@ def test_get_leaderboard_hides_scores_when_show_scores_on_leaderboard_false( self.assertNotIn("filtering_error", entry) self.assertIn("submission__participant_team__team_name", entry) + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "leaderboard-cache-test-1", + } + } + ) + def test_get_leaderboard_uses_cache_on_subsequent_request(self): + cache.clear() + url = reverse_lazy( + "jobs:leaderboard", + kwargs={ + "challenge_phase_split_id": self.challenge_phase_split.id + }, + ) + with mock.patch( + "jobs.views.calculate_distinct_sorted_leaderboard_data", + wraps=calculate_distinct_sorted_leaderboard_data, + ) as wrapped: + first = self.client.get(url, {}) + second = self.client.get(url, {}) + self.assertEqual(first.status_code, status.HTTP_200_OK) + self.assertEqual(second.status_code, status.HTTP_200_OK) + self.assertEqual(wrapped.call_count, 1) + self.assertEqual(first.data["results"], second.data["results"]) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "leaderboard-cache-test-2", + } + } + ) + def test_get_leaderboard_caches_separately_for_host_vs_non_host(self): + cache.clear() + url = reverse_lazy( + "jobs:leaderboard", + kwargs={ + "challenge_phase_split_id": self.challenge_phase_split.id + }, + ) + with mock.patch( + "jobs.views.calculate_distinct_sorted_leaderboard_data", + wraps=calculate_distinct_sorted_leaderboard_data, + ) as wrapped: + self.client.force_authenticate(user=self.user1) + self.client.get(url, {}) + self.client.force_authenticate(user=self.user) + self.client.get(url, {}) + self.assertEqual(wrapped.call_count, 2) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "leaderboard-cache-test-3", + } + } + ) + def test_get_leaderboard_caches_separately_for_different_order_by(self): + cache.clear() + url = reverse_lazy( + "jobs:leaderboard", + kwargs={ + "challenge_phase_split_id": self.challenge_phase_split.id + }, + ) + with mock.patch( + "jobs.views.calculate_distinct_sorted_leaderboard_data", + wraps=calculate_distinct_sorted_leaderboard_data, + ) as wrapped: + self.client.get(url, {}) + self.client.get(url, {"order_by": "test-score"}) + self.assertEqual(wrapped.call_count, 2) + + @override_settings( + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "leaderboard-cache-test-4", + } + } + ) + def test_get_leaderboard_does_not_cache_400_response(self): + cache.clear() + url = reverse_lazy( + "jobs:leaderboard", + kwargs={ + "challenge_phase_split_id": self.private_challenge_phase_split.id + }, + ) + with mock.patch( + "jobs.views.calculate_distinct_sorted_leaderboard_data", + wraps=calculate_distinct_sorted_leaderboard_data, + ) as wrapped: + first = self.client.get(url, {}) + second = self.client.get(url, {}) + self.assertEqual(first.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(second.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(wrapped.call_count, 2) + class UpdateSubmissionTest(BaseAPITestClass): def setUp(self): From 5510b674660c3564a4f2150afdad18af2afd9802 Mon Sep 17 00:00:00 2001 From: Akanshajain231999 Date: Tue, 28 Apr 2026 14:02:22 -0700 Subject: [PATCH 2/2] Backend: Apply black formatting to leaderboard cache tests Co-Authored-By: Claude Opus 4.7 --- tests/unit/jobs/test_views.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/unit/jobs/test_views.py b/tests/unit/jobs/test_views.py index 3c41b2dc25..00c7b6a2ac 100644 --- a/tests/unit/jobs/test_views.py +++ b/tests/unit/jobs/test_views.py @@ -2419,9 +2419,7 @@ def test_get_leaderboard_uses_cache_on_subsequent_request(self): cache.clear() url = reverse_lazy( "jobs:leaderboard", - kwargs={ - "challenge_phase_split_id": self.challenge_phase_split.id - }, + kwargs={"challenge_phase_split_id": self.challenge_phase_split.id}, ) with mock.patch( "jobs.views.calculate_distinct_sorted_leaderboard_data", @@ -2446,9 +2444,7 @@ def test_get_leaderboard_caches_separately_for_host_vs_non_host(self): cache.clear() url = reverse_lazy( "jobs:leaderboard", - kwargs={ - "challenge_phase_split_id": self.challenge_phase_split.id - }, + kwargs={"challenge_phase_split_id": self.challenge_phase_split.id}, ) with mock.patch( "jobs.views.calculate_distinct_sorted_leaderboard_data", @@ -2472,9 +2468,7 @@ def test_get_leaderboard_caches_separately_for_different_order_by(self): cache.clear() url = reverse_lazy( "jobs:leaderboard", - kwargs={ - "challenge_phase_split_id": self.challenge_phase_split.id - }, + kwargs={"challenge_phase_split_id": self.challenge_phase_split.id}, ) with mock.patch( "jobs.views.calculate_distinct_sorted_leaderboard_data",