Skip to content

Commit f52474e

Browse files
authored
Dashboard: don't prefetch latest build (#12400)
get_latest_build was doing something different than the prefetched value, looks like we never actually used this function with `finished=True`, so I went ahead and renamed it and made it a property so we can cache it.
1 parent bf74954 commit f52474e

File tree

6 files changed

+23
-40
lines changed

6 files changed

+23
-40
lines changed

readthedocs/projects/filters.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,7 @@ def filter(self, qs, value):
138138
else:
139139
order_bys.append(field_ordered)
140140

141-
# Prefetch here not only prefetches the query, but it also changes how `get_latest_build`
142-
# works. Normally from templates `project.get_latest_build` only returns
143-
# the latest _finished_ build. But with prefetch, _all_ builds are
144-
# considered and `get_latest_build` will pop the first off this list of
145-
# _all_ builds.
141+
# prefetch_latest_build does some extra optimizations to avoid additional queries.
146142
return qs.prefetch_latest_build().annotate(**annotations).order_by(*order_bys)
147143

148144

readthedocs/projects/models.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -645,9 +645,6 @@ class Project(models.Model):
645645
blank=True,
646646
)
647647

648-
# Property used for storing the latest build for a project when prefetching
649-
LATEST_BUILD_CACHE = "_latest_build"
650-
651648
class Meta:
652649
ordering = ("slug",)
653650
verbose_name = _("project")
@@ -1100,23 +1097,10 @@ def full_find(self, filename, version):
11001097
matches.append(os.path.join(root, match))
11011098
return matches
11021099

1103-
def get_latest_build(self, finished=True):
1104-
"""
1105-
Get latest build for project.
1106-
1107-
:param finished: Return only builds that are in a finished state
1108-
"""
1109-
# Check if there is `_latest_build` attribute in the Queryset.
1110-
# Used for Database optimization.
1111-
if hasattr(self, self.LATEST_BUILD_CACHE):
1112-
if self._latest_build:
1113-
return self._latest_build[0]
1114-
return None
1115-
1116-
kwargs = {"type": "html"}
1117-
if finished:
1118-
kwargs["state"] = "finished"
1119-
return self.builds(manager=INTERNAL).filter(**kwargs).first()
1100+
@cached_property
1101+
def latest_internal_build(self):
1102+
"""Get the latest internal build for the project."""
1103+
return self.builds(manager=INTERNAL).select_related("version").first()
11201104

11211105
def active_versions(self):
11221106
from readthedocs.builds.models import Version

readthedocs/projects/querysets.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from django.db.models import Count
66
from django.db.models import Exists
77
from django.db.models import OuterRef
8-
from django.db.models import Prefetch
98
from django.db.models import Q
109

1110
from readthedocs.core.permissions import AdminPermission
@@ -134,25 +133,26 @@ def max_concurrent_builds(self, project):
134133

135134
def prefetch_latest_build(self):
136135
"""
137-
Prefetch "latest build" for each project.
136+
Prefetch and annotate to avoid N+1 queries.
138137
139138
.. note::
140139
141140
This should come after any filtering.
142141
"""
143142
from readthedocs.builds.models import Build
144143

145-
# Prefetch the latest build for each project.
146-
latest_build = Prefetch(
147-
"builds",
148-
Build.internal.select_related("version").order_by("-date")[:1],
149-
to_attr=self.model.LATEST_BUILD_CACHE,
150-
)
151-
query = self.prefetch_related(latest_build)
144+
# NOTE: prefetching the latest build will perform worse than just
145+
# accessing the latest build for each project.
146+
# While prefetching reduces the number of queries,
147+
# the query used to fetch the latest build can be quite expensive,
148+
# specially in projects with lots of builds.
149+
# Not prefetching here is fine, as this query is paginated by 15
150+
# items per page, so it will generate at most 15 queries.
152151

152+
# This annotation performs fine in all cases.
153153
# Annotate whether the project has a successful build or not,
154154
# to avoid N+1 queries when showing the build status.
155-
return query.annotate(
155+
return self.annotate(
156156
_has_good_build=Exists(Build.internal.filter(project=OuterRef("pk"), success=True))
157157
)
158158

readthedocs/projects/views/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def get_context_data(self, **kwargs):
3030

3131
# Show for the first few builds, return last build state
3232
if project.builds.count() <= 5:
33-
onboard["build"] = project.get_latest_build(finished=False)
33+
onboard["build"] = project.latest_internal_build
3434
if "github" in project.repo:
3535
onboard["provider"] = "github"
3636
elif "bitbucket" in project.repo:

readthedocs/rtd_tests/tests/test_project.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,11 @@ def test_has_good_build_excludes_external_versions(self):
223223
# Test that External Version is not considered for has_good_build.
224224
self.assertFalse(self.pip.has_good_build)
225225

226-
def test_get_latest_build_excludes_external_versions(self):
226+
def test_latest_internal_build_excludes_external_versions(self):
227227
# Delete all versions excluding External Versions.
228228
self.pip.versions.exclude(type=EXTERNAL).delete()
229-
# Test that External Version is not considered for get_latest_build.
230-
self.assertEqual(self.pip.get_latest_build(), None)
229+
# Test that External Version is not considered for latest_internal_build.
230+
self.assertEqual(self.pip.latest_internal_build, None)
231231

232232
def test_git_provider_github(self):
233233
self.pip.repo = "https://github.com/pypa/pip"

readthedocs/rtd_tests/tests/test_project_views.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,10 @@ def test_dashboard_number_of_queries(self):
399399
state=BUILD_STATE_FINISHED,
400400
)
401401

402-
with self.assertNumQueries(12):
402+
# This number is bit higher, but for projects with lots of builds
403+
# is better to have more queries than optimizing with a prefetch,
404+
# see comment in prefetch_latest_build.
405+
with self.assertNumQueries(27):
403406
r = self.client.get(reverse(("projects_dashboard")))
404407
assert r.status_code == 200
405408

0 commit comments

Comments
 (0)