Skip to content

Commit 6697275

Browse files
authored
Build: Denormalize latest build for projects (#12392)
Sorting by the last build project is expensive, as the query is done for each project (even if paginated, since the order applies to all projects). For comparison, here are some numbers with data from production ``` # Ordering by name 145 ms ± 446 μs per loop (mean ± std. dev. of 5 runs, 5 loops each) # Ordering by max(builds_date) 4.37 s ± 254 ms per loop (mean ± std. dev. of 5 runs, 5 loops each) ``` The migration is going to take some time to run, I imagine... but should be worth it for a snappier dashboard.
1 parent 850ec30 commit 6697275

File tree

7 files changed

+121
-1
lines changed

7 files changed

+121
-1
lines changed

readthedocs/builds/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ class Config(AppConfig):
1515

1616
def ready(self):
1717
import readthedocs.builds.tasks # noqa
18+
import readthedocs.builds.signals_receivers # noqa
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
Receiver signals for the Builds app.
3+
4+
NOTE: Done in a separate file to avoid circular imports.
5+
"""
6+
7+
from django.db.models.signals import post_save
8+
from django.dispatch import receiver
9+
10+
from readthedocs.builds.models import Build
11+
from readthedocs.projects.models import Project
12+
13+
14+
@receiver(post_save, sender=Build)
15+
def update_latest_build_for_project(sender, instance, created, **kwargs):
16+
"""When a build is created, update the latest build for the project."""
17+
if created:
18+
Project.objects.filter(pk=instance.project_id).update(
19+
latest_build=instance,
20+
)

readthedocs/builds/tests/test_trigger_build.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,25 @@ def test_not_cancel_old_finished_build(self, update_docs_task, cancel_build, sta
9090
assert builds_count_before == builds_count_after - 1
9191
assert update_docs_task.signature.called
9292
assert update_docs_task.signature().apply_async.called
93+
94+
@mock.patch("readthedocs.core.utils.cancel_build")
95+
@mock.patch("readthedocs.projects.tasks.builds.update_docs_task")
96+
def test_update_latest_build_on_trigger(self, update_docs_task, cancel_build):
97+
assert self.project.builds.count() == 0
98+
assert self.project.latest_build is None
99+
_, build = trigger_build(
100+
project=self.project,
101+
version=self.version,
102+
)
103+
104+
self.project.refresh_from_db()
105+
assert self.project.builds.count() == 1
106+
assert self.project.latest_build == build
107+
108+
_, build = trigger_build(
109+
project=self.project,
110+
version=self.version,
111+
)
112+
self.project.refresh_from_db()
113+
assert self.project.builds.count() == 2
114+
assert self.project.latest_build == build

readthedocs/projects/admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ class ProjectAdmin(ExtraSimpleHistoryAdmin):
240240
"feature_flags",
241241
"matching_spam_rules",
242242
)
243-
raw_id_fields = ("users", "main_language_project", "remote_repository")
243+
raw_id_fields = ("users", "main_language_project", "remote_repository", "latest_build")
244244
actions = [
245245
"ban_owner",
246246
"run_spam_rule_checks",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 5.2.3 on 2025-08-11 21:09
2+
3+
import django.db.models.deletion
4+
from django.db import migrations
5+
from django.db import models
6+
from django_safemigrate import Safe
7+
8+
9+
class Migration(migrations.Migration):
10+
safe = Safe.before_deploy()
11+
dependencies = [
12+
("builds", "0064_healthcheck"),
13+
("projects", "0152_create_gh_app_integration"),
14+
]
15+
16+
operations = [
17+
migrations.AddField(
18+
model_name="historicalproject",
19+
name="latest_build",
20+
field=models.ForeignKey(
21+
blank=True,
22+
db_constraint=False,
23+
null=True,
24+
on_delete=django.db.models.deletion.DO_NOTHING,
25+
related_name="+",
26+
to="builds.build",
27+
verbose_name="Latest build",
28+
),
29+
),
30+
migrations.AddField(
31+
model_name="project",
32+
name="latest_build",
33+
field=models.OneToOneField(
34+
blank=True,
35+
null=True,
36+
on_delete=django.db.models.deletion.SET_NULL,
37+
related_name="+",
38+
to="builds.build",
39+
verbose_name="Latest build",
40+
),
41+
),
42+
]
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 5.2.3 on 2025-08-11 21:16
2+
3+
from django.db import migrations
4+
from django_safemigrate import Safe
5+
6+
7+
def forward(apps, schema_editor):
8+
Project = apps.get_model("projects", "Project")
9+
for project in Project.objects.all().iterator():
10+
latest_build = project.builds.order_by("-date").first()
11+
if latest_build:
12+
project.latest_build = latest_build
13+
project.save()
14+
15+
16+
class Migration(migrations.Migration):
17+
safe = Safe.after_deploy()
18+
dependencies = [
19+
("projects", "0153_project_latest_build"),
20+
]
21+
22+
operations = [
23+
migrations.RunPython(forward),
24+
]

readthedocs/projects/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,17 @@ class Project(models.Model):
634634
null=True,
635635
)
636636

637+
# Denormalized fields
638+
latest_build = models.OneToOneField(
639+
"builds.Build",
640+
verbose_name=_("Latest build"),
641+
# No reverse relation needed.
642+
related_name="+",
643+
on_delete=models.SET_NULL,
644+
null=True,
645+
blank=True,
646+
)
647+
637648
# Property used for storing the latest build for a project when prefetching
638649
LATEST_BUILD_CACHE = "_latest_build"
639650

0 commit comments

Comments
 (0)