Skip to content

Commit f837e8d

Browse files
committed
Add tag-search perf script and changelog entry for the optimization
scripts/perf_tag_search.py drives /questions/tags:.../ with an increasing tag count against a running dev server and prints min/median/max timings; useful for measuring the JOIN-vs-subquery gap before/after the optimization.
1 parent cf01ecb commit f837e8d

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

askbot/doc/source/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Changes in Askbot
33

44
Development (not yet released)
55
------------------------------
6+
* Optimized multi-tag question search: replaced per-tag JOINs with a single subquery
67
* Removed legacy ``askbot/container/`` directory and ``askbot_requirements.txt``
78
* Added test to verify ``askbot.REQUIREMENTS`` and ``pyproject.toml`` dependencies stay in sync
89
* Bumped ``urllib3`` dependency from ``>=1.21.1,<1.27`` to ``>=2,<3``

scripts/perf_tag_search.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""One-off perf script for the tag-intersection query.
2+
3+
Picks a real thread that carries the largest tag set in the database, then
4+
issues GETs to /questions/tags:t1,t2,.../ for increasing tag counts so the
5+
intersection always returns at least one question. Times each request with
6+
warm-up + repeats so the numbers are stable enough to compare branches.
7+
8+
Usage:
9+
cd askbot_site
10+
../env-md/bin/python ../perf_tag_search.py --base-url http://127.0.0.1:8000
11+
12+
The dev server must already be running (manage.py runserver, gunicorn, etc.)
13+
on the URL passed in. Run the script once before applying the optimization
14+
and once after; compare the columns.
15+
"""
16+
import argparse
17+
import os
18+
import statistics
19+
import sys
20+
import time
21+
import urllib.error
22+
import urllib.parse
23+
import urllib.request
24+
25+
import django
26+
27+
28+
def setup_django():
29+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'askbot_site.settings')
30+
if os.getcwd() not in sys.path:
31+
sys.path.insert(0, os.getcwd())
32+
django.setup()
33+
34+
35+
def pick_tag_set(min_count, max_count):
36+
"""Return a list of tag names that all co-occur on the same thread.
37+
38+
Uses the thread carrying the most tags so the intersection is guaranteed
39+
non-empty for any prefix of the returned list.
40+
"""
41+
from askbot.models import Thread
42+
from django.db.models import Count
43+
44+
threads = (
45+
Thread.objects
46+
.annotate(n_tags=Count('tags'))
47+
.filter(n_tags__gte=min_count)
48+
.order_by('-n_tags')
49+
)
50+
thread = threads.first()
51+
if thread is None:
52+
raise SystemExit(
53+
f'No thread has at least {min_count} tags - lower --min-tags or '
54+
'load a larger dataset.'
55+
)
56+
names = list(thread.tags.values_list('name', flat=True))
57+
if max_count:
58+
names = names[:max_count]
59+
print(f'Using thread id={thread.id} with {len(names)} tags: {names}')
60+
return names
61+
62+
63+
def build_url(base_url, tags):
64+
encoded = urllib.parse.quote(','.join(tags), safe=',+.-_')
65+
return f'{base_url.rstrip("/")}/questions/tags:{encoded}/'
66+
67+
68+
def time_request(url, timeout):
69+
start = time.perf_counter()
70+
with urllib.request.urlopen(url, timeout=timeout) as resp:
71+
body = resp.read()
72+
status = resp.status
73+
elapsed = time.perf_counter() - start
74+
return elapsed, status, len(body)
75+
76+
77+
def run(base_url, tags, repeats, warmups, timeout, sleep):
78+
header = f'{"N tags":>6} | {"min ms":>8} | {"med ms":>8} | {"max ms":>8} | {"bytes":>8} | status'
79+
print(header)
80+
print('-' * len(header))
81+
for n in range(1, len(tags) + 1):
82+
url = build_url(base_url, tags[:n])
83+
last_status = None
84+
last_size = None
85+
for _ in range(warmups):
86+
_, last_status, last_size = time_request(url, timeout)
87+
if sleep:
88+
time.sleep(sleep)
89+
samples = []
90+
for _ in range(repeats):
91+
elapsed, last_status, last_size = time_request(url, timeout)
92+
samples.append(elapsed * 1000)
93+
if sleep:
94+
time.sleep(sleep)
95+
print(
96+
f'{n:>6} | {min(samples):>8.1f} | {statistics.median(samples):>8.1f} | '
97+
f'{max(samples):>8.1f} | {last_size:>8} | {last_status}'
98+
)
99+
print(f' url: {url}')
100+
101+
102+
def main():
103+
parser = argparse.ArgumentParser(description=__doc__)
104+
parser.add_argument('--base-url', default='http://127.0.0.1:8000',
105+
help='Base URL of the running askbot site.')
106+
parser.add_argument('--min-tags', type=int, default=2,
107+
help='Skip threads that carry fewer tags than this.')
108+
parser.add_argument('--max-tags', type=int, default=8,
109+
help='Cap the tag list length (0 = no cap).')
110+
parser.add_argument('--repeats', type=int, default=5,
111+
help='Timed requests per tag count.')
112+
parser.add_argument('--warmups', type=int, default=1,
113+
help='Untimed requests per tag count before timing.')
114+
parser.add_argument('--timeout', type=float, default=60.0,
115+
help='Per-request timeout in seconds.')
116+
parser.add_argument('--sleep', type=float, default=1.5,
117+
help='Pause between requests to avoid the rate limiter.')
118+
args = parser.parse_args()
119+
120+
setup_django()
121+
tags = pick_tag_set(args.min_tags, args.max_tags)
122+
print(f'Base URL: {args.base_url}')
123+
print(f'Repeats: {args.repeats}, warmups: {args.warmups}')
124+
print()
125+
try:
126+
run(args.base_url, tags, args.repeats, args.warmups, args.timeout,
127+
args.sleep)
128+
except urllib.error.URLError as exc:
129+
print(f'Request failed - is the dev server running? {exc}', file=sys.stderr)
130+
sys.exit(1)
131+
132+
133+
if __name__ == '__main__':
134+
main()

0 commit comments

Comments
 (0)