Skip to content

Commit 80dd288

Browse files
authored
perf(api): eliminate notes N+1 in finding/engagement/test/risk_acceptance list endpoints (#15274)
The list endpoints that embed NoteSerializer prefetched the notes relation flat, so the serializer lazy-loaded author, editor, note_type and history__current_editor with one query per note (4+ extra queries per note per request). Introduce notes_prefetch() in dojo/notes/helper.py — a nested Prefetch that pulls every relation NoteSerializer renders in bulk — and use it in the four affected viewsets. Measured on the finding list endpoint (25 findings x 5 notes each): 375 queries dropped to 3 constant ones, median response time 299ms -> 144ms (~2.1x); response payload is byte-identical. The regression test asserts the query count is independent of the number of notes on each of the four endpoints.
1 parent 5b3dc72 commit 80dd288

6 files changed

Lines changed: 114 additions & 5 deletions

File tree

dojo/engagement/api/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
NoteHistory,
3434
Notes,
3535
)
36+
from dojo.notes.helper import notes_prefetch
3637
from dojo.product.queries import get_authorized_engagement_presets
3738
from dojo.risk_acceptance import api as ra_api
3839
from dojo.utils import (
@@ -77,7 +78,7 @@ def destroy(self, request, *args, **kwargs):
7778
def get_queryset(self):
7879
return (
7980
get_authorized_engagements("view")
80-
.prefetch_related("notes", "risk_acceptance", "files")
81+
.prefetch_related(notes_prefetch(), "risk_acceptance", "files")
8182
.distinct()
8283
)
8384

dojo/finding/api/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
NoteHistory,
7070
Notes,
7171
)
72+
from dojo.notes.helper import notes_prefetch
7273
from dojo.risk_acceptance import api as ra_api
7374
from dojo.utils import (
7475
generate_file_response,
@@ -192,7 +193,7 @@ def get_queryset(self):
192193
"locations__location__url",
193194
"reviewers",
194195
"found_by",
195-
"notes",
196+
notes_prefetch(),
196197
"risk_acceptance_set",
197198
"test",
198199
"tags",
@@ -216,7 +217,7 @@ def get_queryset(self):
216217
"endpoints",
217218
"reviewers",
218219
"found_by",
219-
"notes",
220+
notes_prefetch(),
220221
"risk_acceptance_set",
221222
"test",
222223
"tags",

dojo/notes/helper.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
11
import logging
22

3+
from django.db.models import Prefetch
4+
5+
from dojo.notes.models import NoteHistory, Notes
6+
37
logger = logging.getLogger(__name__)
48

59

10+
def notes_prefetch(lookup="notes"):
11+
"""
12+
Prefetch for relations rendered by NoteSerializer.
13+
14+
NoteSerializer renders author/editor/note_type on each note and
15+
current_editor/note_type on each history entry; a flat prefetch of the
16+
notes relation leaves those to lazy-load with one query per object (N+1).
17+
"""
18+
return Prefetch(
19+
lookup,
20+
queryset=Notes.objects.select_related("author", "editor", "note_type").prefetch_related(
21+
Prefetch("history", queryset=NoteHistory.objects.select_related("current_editor", "note_type")),
22+
),
23+
)
24+
25+
626
def delete_related_notes(obj):
727
if not hasattr(obj, "notes"):
828
logger.warning(f"Attempted to delete notes from object type {type(obj)} without 'notes' attribute.")

dojo/risk_acceptance/api/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from dojo.api_v2.views import PrefetchDojoModelViewSet
1616
from dojo.authorization import api_permissions as permissions
1717
from dojo.models import NoteHistory, Notes, Risk_Acceptance
18+
from dojo.notes.helper import notes_prefetch
1819
from dojo.risk_acceptance.api.filters import ApiRiskAcceptanceFilter
1920
from dojo.risk_acceptance.api.serializer import (
2021
RiskAcceptanceProofSerializer,
@@ -51,7 +52,7 @@ def get_queryset(self):
5152
return (
5253
get_authorized_risk_acceptances("edit")
5354
.prefetch_related(
54-
"notes", "engagement_set", "owner", "accepted_findings",
55+
notes_prefetch(), "engagement_set", "owner", "accepted_findings",
5556
)
5657
.distinct()
5758
)

dojo/test/api/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Test_Import,
1919
Test_Type,
2020
)
21+
from dojo.notes.helper import notes_prefetch
2122
from dojo.risk_acceptance import api as ra_api
2223
from dojo.test.api.filters import ApiTestFilter, TestImportAPIFilter
2324
from dojo.test.api.serializer import (
@@ -58,7 +59,7 @@ def risk_application_model_class(self):
5859
def get_queryset(self):
5960
return (
6061
get_authorized_tests("view")
61-
.prefetch_related("notes", "files")
62+
.prefetch_related(notes_prefetch(), "files")
6263
.distinct()
6364
)
6465

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
from django.db import connection
2+
from django.test.utils import CaptureQueriesContext
3+
from django.utils.timezone import now
4+
from rest_framework.test import APITestCase
5+
6+
from dojo.models import (
7+
Dojo_User,
8+
Engagement,
9+
Finding,
10+
NoteHistory,
11+
Notes,
12+
Product,
13+
Product_Type,
14+
Risk_Acceptance,
15+
Test,
16+
Test_Type,
17+
)
18+
19+
20+
class TestNotesListNPlusOne(APITestCase):
21+
22+
"""
23+
Regression: list endpoints that embed NoteSerializer (findings, engagements, tests,
24+
risk_acceptance) must load note authors/editors/history in bulk via notes_prefetch().
25+
A flat prefetch of the notes relation leaves NoteSerializer to lazy-load author, editor,
26+
note_type and history__current_editor per note, so the query count grows with every note.
27+
Each test pins the fix by asserting the query count is identical with 1 and with 5 notes.
28+
"""
29+
30+
@classmethod
31+
def setUpTestData(cls):
32+
cls.user = Dojo_User.objects.create(username="np1_user", is_staff=True, is_superuser=True)
33+
cls.prod_type = Product_Type.objects.create(name="NP1 Product Type")
34+
cls.product = Product.objects.create(name="NP1 Product", prod_type=cls.prod_type, description="NP1")
35+
cls.engagement = Engagement.objects.create(
36+
name="NP1 Engagement", product=cls.product, target_start=now(), target_end=now(),
37+
)
38+
cls.test_type = Test_Type.objects.create(name="NP1 Test Type")
39+
cls.test = Test.objects.create(
40+
title="NP1 Test", engagement=cls.engagement, test_type=cls.test_type,
41+
target_start=now(), target_end=now(),
42+
)
43+
cls.finding = Finding.objects.create(title="NP1 Finding", test=cls.test, reporter=cls.user, severity="High")
44+
cls.risk_acceptance = Risk_Acceptance.objects.create(name="NP1 RA", owner=cls.user)
45+
cls.risk_acceptance.engagement_set.add(cls.engagement)
46+
47+
def setUp(self):
48+
self.client.force_authenticate(user=self.user)
49+
50+
def _add_note(self, obj):
51+
# An edited note with a history entry exercises every relation NoteSerializer renders.
52+
note = Notes.objects.create(entry="np1 note", author=self.user, edited=True, editor=self.user)
53+
history = NoteHistory.objects.create(data="np1 history", current_editor=self.user)
54+
note.history.add(history)
55+
obj.notes.add(note)
56+
57+
def _query_count(self, url):
58+
with CaptureQueriesContext(connection) as ctx:
59+
response = self.client.get(url)
60+
self.assertEqual(response.status_code, 200, response.content[:1000])
61+
return len(ctx.captured_queries)
62+
63+
def _assert_count_independent_of_notes(self, obj, url):
64+
self._add_note(obj)
65+
self._query_count(url) # warm-up: fills ContentType and other first-request caches
66+
with_one_note = self._query_count(url)
67+
for _ in range(4):
68+
self._add_note(obj)
69+
with_five_notes = self._query_count(url)
70+
self.assertEqual(
71+
with_one_note, with_five_notes,
72+
f"{url} ran {with_five_notes - with_one_note} extra queries for 4 extra notes (N+1 on notes)",
73+
)
74+
75+
def test_finding_list_query_count_independent_of_notes(self):
76+
self._assert_count_independent_of_notes(self.finding, f"/api/v2/findings/?id={self.finding.id}")
77+
78+
def test_engagement_list_query_count_independent_of_notes(self):
79+
self._assert_count_independent_of_notes(self.engagement, f"/api/v2/engagements/?id={self.engagement.id}")
80+
81+
def test_test_list_query_count_independent_of_notes(self):
82+
self._assert_count_independent_of_notes(self.test, f"/api/v2/tests/?id={self.test.id}")
83+
84+
def test_risk_acceptance_list_query_count_independent_of_notes(self):
85+
self._assert_count_independent_of_notes(self.risk_acceptance, f"/api/v2/risk_acceptance/?id={self.risk_acceptance.id}")

0 commit comments

Comments
 (0)