Skip to content

Commit 25b38f7

Browse files
authored
Bedrock performance tooling and initial optimisations (#15513)
* Add support for Django-silk profiling in local/non-prod only Also add script to hit some common/popular URLs to give djanjo-silk some traffic to capture * Add cacheing to geo.valid_country_code for performance boost Saves 11 SQL queries on the releasnotes page by cacheing the country code lookup for an hour. Tested on /en-US/firefox/132.0.1/releasenotes/ Cold cache: 14 queries / 2066ms Warm cache: 3 queries / 222ms * Rename 'default' cache time to CACHE_TIME_SHORT to make it more meaningful a name * Add cacheing to Careers views to reduce number of DB hits Caches derived values (location options, etc) for longer than the actual page of positions Takes careers listing page down from 9 queries to 2 for the CMS deployment and 0 on the Web deployment * Cache the newsletter lookup for 6 hours It's used on the newsletter form which shows up in a bunch of places on the site. * Add missing subdep to hashed requirements * Reinstate missing function call in _log() helper 🤦
1 parent 9db97b8 commit 25b38f7

File tree

12 files changed

+211
-15
lines changed

12 files changed

+211
-15
lines changed

bedrock/base/geo.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,20 @@
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

55
from django.conf import settings
6+
from django.core.cache import cache
67

78
from product_details import product_details
89

910

1011
def valid_country_code(country):
11-
codes = product_details.get_regions("en-US").keys()
12+
_key = f"valid_country_codes_for_{country}"
13+
14+
codes = cache.get(_key)
15+
16+
if not codes:
17+
codes = product_details.get_regions("en-US").keys()
18+
cache.set(_key, codes, timeout=settings.CACHE_TIME_MED)
19+
1220
if country and country.lower() in codes:
1321
return country.upper()
1422

bedrock/careers/models.py

+37-11
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from datetime import datetime
66
from itertools import chain
77

8+
from django.conf import settings
9+
from django.core.cache import cache
810
from django.db import models
911
from django.urls import reverse
1012

@@ -37,28 +39,52 @@ def __str__(self):
3739
def get_absolute_url(self):
3840
return reverse("careers.position", kwargs={"source": self.source, "job_id": self.job_id})
3941

42+
@classmethod
43+
def _get_cache_key(cls, name):
44+
return f"careers_position__{name}"
45+
4046
@property
4147
def location_list(self):
42-
return sorted(self.location.split(","))
48+
_key = self._get_cache_key("location_list")
49+
location_list = cache.get(_key)
50+
if location_list is None:
51+
location_list = sorted(self.location.split(","))
52+
cache.set(_key, location_list, settings.CACHE_TIME_LONG)
53+
return location_list
4354

4455
@classmethod
4556
def position_types(cls):
46-
return sorted(set(cls.objects.values_list("position_type", flat=True)))
57+
_key = cls._get_cache_key("position_types")
58+
position_types = cache.get(_key)
59+
if position_types is None:
60+
position_types = sorted(set(cls.objects.values_list("position_type", flat=True)))
61+
cache.set(_key, position_types, settings.CACHE_TIME_LONG)
62+
return position_types
4763

4864
@classmethod
4965
def locations(cls):
50-
return sorted(
51-
{
52-
location.strip()
53-
for location in chain(
54-
*[locations.split(",") for locations in cls.objects.exclude(job_locations="Remote").values_list("job_locations", flat=True)]
55-
)
56-
}
57-
)
66+
_key = cls._get_cache_key("locations")
67+
locations = cache.get(_key)
68+
if locations is None:
69+
locations = sorted(
70+
{
71+
location.strip()
72+
for location in chain(
73+
*[locations.split(",") for locations in cls.objects.exclude(job_locations="Remote").values_list("job_locations", flat=True)]
74+
)
75+
}
76+
)
77+
cache.set(_key, locations, settings.CACHE_TIME_LONG)
78+
return locations
5879

5980
@classmethod
6081
def categories(cls):
61-
return sorted(set(cls.objects.values_list("department", flat=True)))
82+
_key = cls._get_cache_key("categories")
83+
categories = cache.get(_key)
84+
if categories is None:
85+
categories = sorted(set(cls.objects.values_list("department", flat=True)))
86+
cache.set(_key, categories, settings.CACHE_TIME_LONG)
87+
return categories
6288

6389
@property
6490
def cover(self):

bedrock/careers/tests/test_forms.py

+6
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5+
6+
from django.core.cache import cache
7+
58
from bedrock.careers.forms import PositionFilterForm
69
from bedrock.careers.tests import PositionFactory
710
from bedrock.mozorg.tests import TestCase
811

912

1013
class PositionFilterFormTests(TestCase):
14+
def setUp(self):
15+
cache.clear()
16+
1117
def test_dynamic_position_type_choices(self):
1218
"""
1319
The choices for the position_type field should be dynamically

bedrock/careers/tests/test_models.py

+5
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5+
from django.core.cache import cache
6+
57
from bedrock.careers.models import Position
68
from bedrock.careers.tests import PositionFactory
79
from bedrock.mozorg.tests import TestCase
810

911

1012
class TestPositionModel(TestCase):
13+
def setUp(self):
14+
cache.clear()
15+
1116
def test_location_list(self):
1217
PositionFactory(location="San Francisco,Portland")
1318
pos = Position.objects.get()

bedrock/careers/tests/test_utils.py

+3
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5+
from django.core.cache import cache
6+
57
from bedrock.careers.tests import PositionFactory
68
from bedrock.careers.utils import generate_position_meta_description
79
from bedrock.mozorg.tests import TestCase
810

911

1012
class GeneratePositionMetaDescriptionTests(TestCase):
1113
def setUp(self):
14+
cache.clear()
1215
self.position = PositionFactory(title="Bowler", position_type="Full time", location="Los Angeles,Ralphs")
1316

1417
def test_position_type_consonant_beginning(self):

bedrock/careers/views.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5+
from django.conf import settings
6+
from django.core.cache import cache
57
from django.http.response import Http404
68
from django.shortcuts import get_object_or_404
79
from django.views.generic import DetailView, ListView
@@ -62,10 +64,17 @@ class BenefitsView(L10nTemplateView):
6264

6365

6466
class PositionListView(LangFilesMixin, RequireSafeMixin, ListView):
65-
queryset = Position.objects.exclude(job_locations="Remote")
6667
template_name = "careers/listings.html"
6768
context_object_name = "positions"
6869

70+
def get_queryset(self):
71+
_key = "careers_position_listing_qs"
72+
qs = cache.get(_key)
73+
if qs is None:
74+
qs = Position.objects.exclude(job_locations="Remote")
75+
cache.set(_key, qs, settings.CACHE_TIME_SHORT)
76+
return qs
77+
6978
def get_context_data(self, **kwargs):
7079
context = super().get_context_data(**kwargs)
7180
context["form"] = PositionFilterForm()

bedrock/newsletter/utils.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
# License, v. 2.0. If a copy of the MPL was not distributed with this
33
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5+
from django.conf import settings
6+
from django.core.cache import cache
7+
58
import basket
69

710
from bedrock.newsletter.models import Newsletter
@@ -12,7 +15,12 @@ def get_newsletters():
1215
Keys are the internal keys we use to designate newsletters to basket.
1316
Values are dictionaries with the remaining newsletter information.
1417
"""
15-
return Newsletter.objects.serialize()
18+
_key = "serialized_newsletters"
19+
serialized_newsletters = cache.get(_key)
20+
if serialized_newsletters is None:
21+
serialized_newsletters = Newsletter.objects.serialize()
22+
cache.set(_key, serialized_newsletters, timeout=settings.CACHE_TIME_LONG)
23+
return serialized_newsletters
1624

1725

1826
def get_languages_for_newsletters(newsletters=None):

bedrock/settings/base.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,16 @@ def data_path(*args):
8686
"image_renditions": {"URL": f"{REDIS_URL}/0"},
8787
}
8888

89+
CACHE_TIME_SHORT = 60 * 10 # 10 mins
90+
CACHE_TIME_MED = 60 * 60 # 1 hour
91+
CACHE_TIME_LONG = 60 * 60 * 6 # 6 hours
92+
93+
8994
CACHES = {
9095
"default": {
9196
"BACKEND": "bedrock.base.cache.SimpleDictCache",
9297
"LOCATION": "default",
93-
"TIMEOUT": 600,
98+
"TIMEOUT": CACHE_TIME_SHORT,
9499
"OPTIONS": {
95100
"MAX_ENTRIES": 5000,
96101
"CULL_FREQUENCY": 4, # 1/4 entries deleted if max reached
@@ -2443,3 +2448,11 @@ def lazy_wagtail_langs():
24432448
# Useful when customising the Wagtail admin
24442449
# when enabled, will be visible on cms-admin/styleguide
24452450
INSTALLED_APPS.append("wagtail.contrib.styleguide")
2451+
2452+
# Django-silk for performance profiling
2453+
if ENABLE_DJANGO_SILK := config("ENABLE_DJANGO_SILK", default="False", parser=bool):
2454+
print("Django-Silk profiling enabled - go to http://localhost:8000/silk/ to view metrics")
2455+
INSTALLED_APPS.append("silk")
2456+
MIDDLEWARE.insert(0, "silk.middleware.SilkyMiddleware")
2457+
SUPPORTED_NONLOCALES.append("silk")
2458+
SILKY_PYTHON_PROFILER = config("SILKY_PYTHON_PROFILER", default="False", parser=bool)

bedrock/urls.py

+3
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
path("_internal_draft_preview/", include(wagtaildraftsharing_urls)), # ONLY available in CMS mode
7373
)
7474

75+
if settings.ENABLE_DJANGO_SILK:
76+
urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))]
77+
7578
if settings.DEFAULT_FILE_STORAGE == "django.core.files.storage.FileSystemStorage":
7679
# Serve media files from Django itself - production won't use this
7780
from django.urls import re_path

profiling/hit_popular_pages.py

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# This Source Code Form is subject to the terms of the Mozilla Public
2+
# License, v. 2.0. If a copy of the MPL was not distributed with this
3+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
"""Request a selection of pages that are populat on www.m.o from your local
6+
runserver, so that django-silk can capture performance info on them.
7+
8+
Usage:
9+
10+
1. In your .env set ENABLE_DJANGO_SILK=True
11+
2. Start your runserver on port 8000
12+
3. python profiling/hit_popular_pages.py
13+
3. View results at http://localhost:8000/silk/
14+
15+
"""
16+
17+
import sys
18+
import time
19+
20+
import requests
21+
22+
paths = [
23+
"/en-US/firefox/126.0/whatsnew/",
24+
"/en-US/firefox/",
25+
"/en-US/firefox/windows/",
26+
"/en-US/firefox/new/?reason=manual-update",
27+
"/en-US/firefox/download/thanks/",
28+
"/en-US/firefox/new/?reason=outdated",
29+
"/en-US/firefox/features/",
30+
"/en-US/firefox/all/",
31+
"/en-US/firefox/welcome/18/",
32+
"/en-US/",
33+
"/en-US/firefox/installer-help/?channel=release&installer_lang=en-US",
34+
"/en-US/firefox/download/thanks/?s=direct",
35+
"/en-US/firefox/welcome/19/",
36+
"/en-US/firefox/enterprise/?reason=manual-update",
37+
"/en-US/products/vpn/",
38+
"/en-US/firefox/browsers/windows-64-bit/",
39+
"/en-US/firefox/mac/",
40+
"/en-US/about/",
41+
"/en-US/firefox/android/124.0/releasenotes/",
42+
"/en-US/firefox/browsers/mobile/get-app/",
43+
"/en-US/firefox/browsers/",
44+
"/en-US/firefox/nightly/firstrun/",
45+
"/en-US/firefox/developer/",
46+
"/en-US/account/",
47+
"/en-US/contribute/",
48+
"/en-US/firefox/browsers/mobile/android/",
49+
"/en-US/privacy/archive/firefox-fire-tv/2023-06/",
50+
"/en-US/firefox/121.0/system-requirements/",
51+
"/en-US/firefox/browsers/mobile/",
52+
"/en-US/firefox/releases/",
53+
"/en-US/MPL/",
54+
"/en-US/firefox/enterprise/",
55+
"/en-US/security/advisories/",
56+
"/en-US/firefox/browsers/what-is-a-browser/",
57+
"/en-US/firefox/channel/desktop/?reason=manual-update",
58+
"/en-US/firefox/pocket/",
59+
"/en-US/firefox/channel/desktop/",
60+
"/en-US/firefox/welcome/17b/",
61+
"/en-US/firefox/welcome/17c/",
62+
"/en-US/firefox/welcome/17a/",
63+
"/en-US/firefox/set-as-default/thanks/",
64+
"/en-US/careers/listings/",
65+
"/en-US/firefox/browsers/chromebook/",
66+
"/en-US/firefox/nothing-personal/",
67+
"/en-US/newsletter/existing/",
68+
"/en-US/about/legal/terms/firefox/",
69+
"/en-US/firefox/linux/",
70+
"/en-US/firefox/browsers/mobile/focus/",
71+
"/en-US/products/vpn/download/",
72+
"/en-US/about/manifesto/",
73+
"/en-US/stories/joy-of-color/",
74+
"/en-US/contact/",
75+
"/en-US/about/legal/defend-mozilla-trademarks/",
76+
]
77+
78+
79+
def _log(*args):
80+
sys.stdout.write("\n".join(args))
81+
82+
83+
def hit_pages(paths, times=3):
84+
_base_url = "http://localhost:8000"
85+
86+
for path in paths:
87+
for _ in range(times):
88+
time.sleep(0.5)
89+
url = f"{_base_url}{path}"
90+
requests.get(url)
91+
92+
_log("All done")
93+
94+
95+
if __name__ == "__main__":
96+
hit_pages(paths)

requirements/dev.in

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
bpython==0.24
44
braceexpand==0.1.7
5+
django-silk==5.3.1
56
factory-boy==3.3.1
67
freezegun==1.5.1
78
markdown-it-py>=2.2.0

0 commit comments

Comments
 (0)