Skip to content

Commit 190b978

Browse files
test: pin query-count baselines for tag inheritance hot paths
Adds unittests/test_tag_inheritance_perf.py with assertNumQueries baselines on the six hottest tag inheritance paths (Product tag add/remove propagating to N findings, child create under inheritance, sticky enforcement on child tag edits). Numbers are pinned against current `dev` behavior so subsequent optimization work shows up as concrete query-count reductions instead of relying on manual benchmarking. The class is intentionally temporary: pins move down as the redesign work lands and the file can be deleted once the targets are met.
1 parent 861973b commit 190b978

1 file changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
"""
2+
Query-count baselines for tag inheritance hot paths.
3+
4+
These tests pin the *current* number of SQL queries DefectDojo issues for each
5+
hot path under tag inheritance. They are tripwires: future redesign work
6+
(see /home/valentijn/.claude/plans/tag-inheritance-redesign.md) will reduce
7+
these numbers, and any regression that pushes them back up will fail loudly.
8+
9+
Each test:
10+
- sets up its fixture *outside* the query-counting block
11+
- exercises one operation under ``assertNumQueries`` with the pinned baseline
12+
- asserts a positive correctness check so query tightening cannot smuggle in
13+
a behavior bug.
14+
"""
15+
from __future__ import annotations
16+
17+
import logging
18+
19+
from django.contrib.auth.models import User
20+
from django.test import override_settings
21+
from django.utils import timezone
22+
23+
from dojo.models import Engagement, Finding, Product, Product_Type, Test, Test_Type
24+
from dojo.product.helpers import propagate_tags_on_product_sync
25+
from unittests.dojo_test_case import DojoTestCase
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
def _make_product_with_findings(name: str, *, n_findings: int, tags: list[str] | None = None) -> Product:
31+
"""
32+
Create a Product → Engagement → Test → N Findings tree with inheritance enabled.
33+
34+
Returns the Product. Internal-only: signal-driven inheritance fires during
35+
creation, but those queries are not counted (we measure operations against
36+
the already-built fixture).
37+
"""
38+
if tags is None:
39+
tags = []
40+
now = timezone.now()
41+
user, _ = User.objects.get_or_create(username="tag_perf_user", defaults={"is_active": True})
42+
pt, _ = Product_Type.objects.get_or_create(name="Tag Perf Type")
43+
product = Product.objects.create(
44+
name=name,
45+
description="perf",
46+
prod_type=pt,
47+
enable_product_tag_inheritance=True,
48+
)
49+
if tags:
50+
product.tags.add(*tags)
51+
eng = Engagement.objects.create(product=product, target_start=now, target_end=now)
52+
tt, _ = Test_Type.objects.get_or_create(name="Tag Perf Test")
53+
test = Test.objects.create(engagement=eng, test_type=tt, target_start=now, target_end=now)
54+
for i in range(n_findings):
55+
Finding.objects.create(
56+
test=test,
57+
title=f"Tag Perf Finding {i}",
58+
severity="Medium",
59+
reporter=user,
60+
)
61+
return product
62+
63+
64+
@override_settings(
65+
CELERY_TASK_ALWAYS_EAGER=True,
66+
CELERY_TASK_EAGER_PROPAGATES=True,
67+
)
68+
class TagInheritancePerfBaselines(DojoTestCase):
69+
70+
"""
71+
Pinned query-count baselines for tag inheritance hot paths.
72+
73+
Celery handling: ``CELERY_TASK_ALWAYS_EAGER=True`` runs all dispatched
74+
tasks synchronously in the test thread/connection so their queries are
75+
captured by ``assertNumQueries``. ``CELERY_TASK_EAGER_PROPAGATES`` makes
76+
eager-mode failures surface as exceptions rather than silently swallowing.
77+
Product tag tests still call ``propagate_tags_on_product_sync(product)``
78+
explicitly because the m2m_changed signal dispatches the Celery task
79+
with ``countdown=5`` — eager mode runs immediately but ``dojo_dispatch_task``
80+
paths can still skip execution depending on ``we_want_async``. Calling the
81+
sync entry point directly makes the propagation deterministic.
82+
83+
84+
TEMPORARY: this test class exists to measure progress as the tag
85+
inheritance redesign lands across multiple PRs. The exact pinned numbers
86+
are not important on their own — what matters is that they MOVE in the
87+
expected direction (downward) as PR #1 (Phase A) and PR #2 (Phase B)
88+
land. Once the redesign is complete and the numbers have stabilized at
89+
target levels, this whole file can be deleted or the assertions
90+
rewritten as loose upper bounds.
91+
92+
Numbers are *current behavior*. Follow-up PRs reduce them. When a
93+
redesign lowers a number, lower the pin in the same PR; when something
94+
accidentally raises it, fix the regression rather than raising the pin.
95+
"""
96+
97+
@classmethod
98+
def setUpTestData(cls):
99+
# Enable system-wide inheritance for every test in this class.
100+
# Per-product flag is also set in _make_product_with_findings as a
101+
# belt-and-braces measure (tests should not be flag-coupled).
102+
from dojo.models import System_Settings # noqa: PLC0415
103+
ss = System_Settings.objects.get()
104+
ss.enable_product_tag_inheritance = True
105+
ss.save()
106+
107+
# ------------------------------------------------------------------
108+
# Product tag add / remove → propagate to children
109+
# ------------------------------------------------------------------
110+
111+
def test_baseline_product_tag_add_propagates_to_100_findings(self):
112+
"""
113+
`product.tags.add("x")` then sync → propagate to 100 findings.
114+
115+
Hot path: Product tag toggle in the UI on a product with many
116+
findings. Today's flow runs `obj.save()` per child. Phase A bulk SQL
117+
will collapse this dramatically.
118+
"""
119+
product = _make_product_with_findings("perf-add", n_findings=100, tags=["initial"])
120+
121+
with self.assertNumQueries(self.EXPECTED_PRODUCT_TAG_ADD_100):
122+
product.tags.add("perf-added")
123+
propagate_tags_on_product_sync(product)
124+
125+
# Correctness: a finding under the product now carries the new tag.
126+
finding = Finding.objects.filter(test__engagement__product=product).first()
127+
self.assertIn("perf-added", [t.name for t in finding.tags.all()])
128+
129+
def test_baseline_product_tag_remove_propagates_to_100_findings(self):
130+
"""`product.tags.remove("x")` then sync → remove from 100 findings."""
131+
product = _make_product_with_findings("perf-remove", n_findings=100, tags=["to-remove", "stays"])
132+
133+
with self.assertNumQueries(self.EXPECTED_PRODUCT_TAG_REMOVE_100):
134+
product.tags.remove("to-remove")
135+
propagate_tags_on_product_sync(product)
136+
137+
finding = Finding.objects.filter(test__engagement__product=product).first()
138+
finding_tag_names = {t.name for t in finding.tags.all()}
139+
self.assertNotIn("to-remove", finding_tag_names)
140+
self.assertIn("stays", finding_tag_names)
141+
142+
# ------------------------------------------------------------------
143+
# Child creation under inheritance-on product
144+
# ------------------------------------------------------------------
145+
146+
def test_baseline_create_one_finding_under_inheritance(self):
147+
"""
148+
Single Finding.objects.create() on inheritance-on product.
149+
150+
post_save fires `inherit_tags_on_instance` which calls
151+
`_manage_inherited_tags` → 2 m2m `.set()` calls per save. This
152+
baseline pins the per-finding cost. Phase A gates on `created=True`
153+
so updates stop paying it; Phase B replaces the M2M dance with a
154+
single JSON column write.
155+
"""
156+
product = _make_product_with_findings("perf-create-one", n_findings=0, tags=["t1", "t2"])
157+
engagement = Engagement.objects.filter(product=product).first()
158+
test = Test.objects.filter(engagement=engagement).first()
159+
user = User.objects.get(username="tag_perf_user")
160+
161+
with self.assertNumQueries(self.EXPECTED_CREATE_ONE_FINDING):
162+
Finding.objects.create(
163+
test=test,
164+
title="single-perf",
165+
severity="Medium",
166+
reporter=user,
167+
)
168+
169+
# Finding.save() titlecases + truncates the title — look up via test FK
170+
finding = Finding.objects.filter(test=test).first()
171+
self.assertIsNotNone(finding)
172+
self.assertEqual({"t1", "t2"}, {t.name for t in finding.tags.all()})
173+
174+
def test_baseline_create_100_findings_under_inheritance(self):
175+
"""
176+
100 sequential Finding.objects.create() under inheritance.
177+
178+
Approximates an importer hot loop. Today every iteration fires
179+
`_manage_inherited_tags` per finding. After Phase B, wrapping in
180+
`with tag_inheritance.batch():` should collapse to a single bulk
181+
sync at exit.
182+
"""
183+
product = _make_product_with_findings("perf-create-100", n_findings=0, tags=["t1", "t2"])
184+
engagement = Engagement.objects.filter(product=product).first()
185+
test = Test.objects.filter(engagement=engagement).first()
186+
user = User.objects.get(username="tag_perf_user")
187+
188+
with self.assertNumQueries(self.EXPECTED_CREATE_100_FINDINGS):
189+
for i in range(100):
190+
Finding.objects.create(
191+
test=test,
192+
title=f"loop-{i}",
193+
severity="Medium",
194+
reporter=user,
195+
)
196+
197+
self.assertEqual(100, Finding.objects.filter(test=test).count())
198+
any_finding = Finding.objects.filter(test=test).first()
199+
self.assertEqual({"t1", "t2"}, {t.name for t in any_finding.tags.all()})
200+
201+
# ------------------------------------------------------------------
202+
# Sticky enforcement on child tag edits
203+
# ------------------------------------------------------------------
204+
205+
def test_baseline_finding_add_user_tag_sticky_path(self):
206+
"""
207+
`finding.tags.add("user-only")` — sticky signal still runs.
208+
209+
Adding a *non-inherited* tag still fires `m2m_changed` →
210+
`make_inherited_tags_sticky` → re-checks product tags. Phase B
211+
moves this work out of the signal entirely.
212+
"""
213+
product = _make_product_with_findings("perf-sticky-add", n_findings=1, tags=["inherited"])
214+
finding = Finding.objects.filter(test__engagement__product=product).first()
215+
216+
with self.assertNumQueries(self.EXPECTED_FINDING_ADD_USER_TAG):
217+
finding.tags.add("user-only")
218+
219+
finding_tag_names = {t.name for t in finding.tags.all()}
220+
self.assertIn("user-only", finding_tag_names)
221+
self.assertIn("inherited", finding_tag_names) # still sticky
222+
223+
def test_baseline_finding_remove_inherited_tag_sticky_re_adds(self):
224+
"""
225+
`finding.tags.remove("inherited")` — sticky re-adds.
226+
227+
Most expensive sticky path: signal re-applies inherited tags via
228+
`inherit_tags` → `_manage_inherited_tags` → 2 M2M `.set()` calls.
229+
"""
230+
product = _make_product_with_findings("perf-sticky-rm", n_findings=1, tags=["inherited"])
231+
finding = Finding.objects.filter(test__engagement__product=product).first()
232+
233+
with self.assertNumQueries(self.EXPECTED_FINDING_REMOVE_INHERITED):
234+
finding.tags.remove("inherited")
235+
236+
# Sticky re-adds the inherited tag
237+
self.assertIn("inherited", {t.name for t in finding.tags.all()})
238+
239+
# ------------------------------------------------------------------
240+
# Pinned baselines (current code; tighten in PR #1 / PR #2)
241+
# ------------------------------------------------------------------
242+
# Calibrated against current implementation. If a redesign lowers a
243+
# number, lower the pin in the same PR. If a regression raises it, fix
244+
# the regression. NEVER raise a pin without justification.
245+
246+
# Calibrated against current `dev` branch behavior.
247+
# Tighten as PR #1 (Phase A) and PR #2 (Phase B) land.
248+
EXPECTED_PRODUCT_TAG_ADD_100 = 4758
249+
EXPECTED_PRODUCT_TAG_REMOVE_100 = 4540
250+
EXPECTED_CREATE_ONE_FINDING = 64
251+
EXPECTED_CREATE_100_FINDINGS = 4025
252+
EXPECTED_FINDING_ADD_USER_TAG = 17
253+
EXPECTED_FINDING_REMOVE_INHERITED = 44

0 commit comments

Comments
 (0)