Skip to content

Commit f2e997c

Browse files
authored
Addons: improve performance when fetching translations (#12329)
Did some benchmarking with [django-silk ](https://github.com/jazzband/django-silk) <img width="456" height="195" alt="Screenshot 2025-07-16 at 17-51-59 Silky - Profiling -" src="https://github.com/user-attachments/assets/f89bd7aa-362e-4a25-92d7-29b927f98a43" /> The main thing is that sorting introduces some overhead to the query, calling exists doesn't require sorting, so it's faster. ``` Query Plan Limit (cost=0.14..18.32 rows=1 width=4) -> Limit (cost=0.14..18.32 rows=1 width=4) -> Nested Loop (cost=0.14..18.32 rows=1 width=4) Join Filter: (projects_project.id = u0.id) -> Seq Scan on projects_project (cost=0.00..10.15 rows=1 width=4) Filter: ((id <> 28) AND ((privacy_level)::text = 'public'::text)) -> Index Scan using projects_project_main_language_project_id_5215229a on projects_project u0 (cost=0.14..8.15 rows=1 width=4) Index Cond: (main_language_project_id = 28) ``` vs ``` Unique (cost=27.94..28.16 rows=1 width=10546) -> Sort (cost=27.94..27.94 rows=1 width=10546) Sort Key: projects_project.language, projects_project.id, projects_project.pub_date, projects_project.modified_date, projects_project.name, projects_project.slug, projects_project.description, projects_project.repo, projects_project.repo_type, projects_project.project_url, projects_project.canonical_url, projects_project.versioning_scheme, projects_project.single_version, projects_project.default_version, projects_project.default_branch, projects_project.custom_prefix, projects_project.custom_subproject_prefix, projects_project.external_builds_enabled, projects_project.external_builds_privacy_level, projects_project.show_build_overview_in_comment, projects_project.cdn_enabled, projects_project.analytics_code, projects_project.analytics_disabled, projects_project.container_image, projects_project.container_mem_limit, projects_project.container_time_limit, projects_project.build_queue, projects_project.max_concurrent_builds, projects_project.allow_promos, projects_project.ad_free, projects_project.is_spam, projects_project.show_version_warning, projects_project.readthedocs_yaml_path, projects_project.featured, projects_project.skip, projects_project.delisted, projects_project.programming_language, projects_project.main_language_project_id, projects_project.has_valid_webhook, projects_project.has_valid_clone, projects_project.remote_repository_id, projects_project.documentation_type, projects_project.has_ssh_key_with_write_access, t2.id, t2.pub_date, t2.modified_date, t2.name, t2.slug, t2.description, t2.repo, t2.repo_type, t2.project_url, t2.canonical_url, t2.versioning_scheme, t2.single_version, t2.default_version, t2.default_branch, t2.custom_prefix, t2.custom_subproject_prefix, t2.external_builds_enabled, t2.external_builds_privacy_level, t2.show_build_overview_in_comment, t2.cdn_enabled, t2.analytics_code, t2.analytics_disabled, t2.container_image, t2.container_mem_limit, t2.container_time_limit, t2.build_queue, t2.max_concurrent_builds, t2.allow_promos, t2.ad_free, t2.is_spam, t2.show_version_warning, t2.readthedocs_yaml_path, t2.featured, t2.skip, t2.delisted, t2.privacy_level, t2.language, t2.programming_language, t2.main_language_project_id, t2.has_valid_webhook, t2.has_valid_clone, t2.remote_repository_id, t2.documentation_type, t2.has_ssh_key_with_write_access -> Nested Loop (cost=0.27..27.93 rows=1 width=10546) Join Filter: (projects_project.id = u0.id) -> Nested Loop Left Join (cost=0.14..19.76 rows=1 width=10546) -> Seq Scan on projects_project (cost=0.00..10.15 rows=1 width=5273) Filter: (((slug)::text <> 'test-builds'::text) AND ((privacy_level)::text = 'public'::text)) -> Index Scan using projects_project_pkey on projects_project t2 (cost=0.14..8.15 rows=1 width=5273) Index Cond: (id = projects_project.main_language_project_id) -> Index Scan using projects_project_main_language_project_id_5215229a on projects_project u0 (cost=0.14..8.15 rows=1 width=4) Index Cond: (main_language_project_id = 28) ``` What about when the project has translations? Looks like times are around the same, but I did manage to reduce the number of queries, which will improve perf only for projects with lots of translations (I do have another suggestion to just remove some fields, but for another PR...)
1 parent 10bcde5 commit f2e997c

File tree

2 files changed

+45
-31
lines changed

2 files changed

+45
-31
lines changed

readthedocs/proxito/tests/test_hosting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,7 +892,7 @@ def test_number_of_queries_url_translations(self):
892892
language=language,
893893
)
894894

895-
with self.assertNumQueries(42):
895+
with self.assertNumQueries(39):
896896
r = self.client.get(
897897
reverse("proxito_readthedocs_docs_addons"),
898898
{

readthedocs/proxito/views/hosting.py

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import structlog
77
from django.conf import settings
88
from django.contrib.auth.models import AnonymousUser
9+
from django.db.models import Q
910
from django.http import Http404
1011
from django.http import JsonResponse
1112
from django.shortcuts import get_object_or_404
@@ -341,7 +342,6 @@ def _v1(self, project, version, build, filename, url, request):
341342
resolver = Resolver()
342343
versions_active_built_not_hidden = Version.objects.none()
343344
sorted_versions_active_built_not_hidden = Version.objects.none()
344-
user = request.user
345345

346346
versions_active_built_not_hidden = (
347347
self._get_versions(request, project).select_related("project").order_by("-slug")
@@ -380,37 +380,11 @@ def _v1(self, project, version, build, filename, url, request):
380380
project.addons.flyout_sorting_latest_stable_at_beginning,
381381
)
382382

383-
main_project = project.main_language_project or project
384-
385-
# Exclude the current project since we don't want to return itself as a translation
386-
project_translations = (
387-
Project.objects.public(user=user)
388-
.filter(pk__in=main_project.translations.all())
389-
.exclude(slug=project.slug)
390-
)
391-
392-
# Include main project as translation if the current project is one of the translations
393-
if project != main_project:
394-
project_translations |= Project.objects.public(user=user).filter(slug=main_project.slug)
395-
project_translations = project_translations.order_by("language").select_related(
396-
"main_language_project"
397-
)
398-
399383
data = {
400384
"api_version": "1",
401-
"projects": {
402-
"current": ProjectSerializerNoLinks(
403-
project,
404-
resolver=resolver,
405-
version_slug=version.slug if version else None,
406-
).data,
407-
"translations": ProjectSerializerNoLinks(
408-
project_translations,
409-
resolver=resolver,
410-
version_slug=version.slug if version else None,
411-
many=True,
412-
).data,
413-
},
385+
"projects": self._get_projects_response(
386+
request=request, project=project, version=version, resolver=resolver
387+
),
414388
"versions": {
415389
"current": VersionSerializerNoLinks(
416390
version,
@@ -617,6 +591,46 @@ def _v1(self, project, version, build, filename, url, request):
617591

618592
return data
619593

594+
def _get_projects_response(self, *, request, project, version, resolver):
595+
main_project = project.main_language_project or project
596+
597+
translation_filter = Q(pk__in=main_project.translations.all())
598+
# Include main project as translation if the current project is one of the translations
599+
if main_project != project:
600+
translation_filter |= Q(pk=main_project.pk)
601+
602+
translations_qs = (
603+
Project.objects.public(user=request.user)
604+
.filter(translation_filter)
605+
# Exclude the current project since we don't want to return itself as a translation
606+
.exclude(pk=project.pk)
607+
.order_by("language")
608+
.select_related("main_language_project")
609+
.prefetch_related("tags", "domains", "related_projects", "users")
610+
)
611+
# NOTE: we check if there are translations first,
612+
# otherwise evaluating the queryset will be more expensive
613+
# even if there are no results. Django optimizes the queryset
614+
# if only we need to check if there are results or not.
615+
if translations_qs.exists():
616+
translations = ProjectSerializerNoLinks(
617+
translations_qs,
618+
resolver=resolver,
619+
version_slug=version.slug if version else None,
620+
many=True,
621+
).data
622+
else:
623+
translations = []
624+
625+
return {
626+
"current": ProjectSerializerNoLinks(
627+
project,
628+
resolver=resolver,
629+
version_slug=version.slug if version else None,
630+
).data,
631+
"translations": translations,
632+
}
633+
620634
def _get_filetreediff_response(self, *, request, project, version, resolver):
621635
"""
622636
Get the file tree diff response for the given version.

0 commit comments

Comments
 (0)