Skip to content

Commit 40cb9cf

Browse files
authored
feat: allow section-based Surrogate-Key to purge chunks (#2851)
1 parent 897fe6d commit 40cb9cf

6 files changed

Lines changed: 148 additions & 27 deletions

File tree

.github/workflows/purge-cache.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Purge Fastly Cache
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths: ['static/**', 'templates/**', '**/templates/**']
7+
workflow_dispatch:
8+
inputs:
9+
target:
10+
description: 'Surrogate key to purge'
11+
required: true
12+
default: 'pydotorg-app'
13+
type: choice
14+
options: [pydotorg-app, downloads, events, sponsors, jobs]
15+
16+
permissions: {}
17+
18+
jobs:
19+
purge:
20+
runs-on: ubuntu-latest
21+
env:
22+
KEY: ${{ inputs.target || 'pydotorg-app' }}
23+
steps:
24+
- name: Purge ${{ env.KEY }}
25+
run: |
26+
curl -fsS -X POST \
27+
"https://api.fastly.com/service/${{ secrets.FASTLY_SERVICE_ID }}/purge/${{ env.KEY }}" \
28+
-H "Fastly-Key: ${{ secrets.FASTLY_API_KEY }}"

apps/downloads/models.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from apps.cms.models import ContentManageable, NameSlugModel
1717
from apps.downloads.managers import ReleaseManager
1818
from apps.pages.models import Page
19-
from fastly.utils import purge_url
19+
from fastly.utils import purge_surrogate_key, purge_url
2020

2121
DEFAULT_MARKUP_TYPE = getattr(settings, "DEFAULT_MARKUP_TYPE", "markdown")
2222

@@ -288,38 +288,35 @@ def promote_latest_release(sender, instance, **kwargs):
288288

289289
@receiver(post_save, sender=Release)
290290
def purge_fastly_download_pages(sender, instance, **kwargs):
291-
"""Purge Fastly caches so new Downloads show up more quickly."""
291+
"""Purge Fastly caches so new Downloads show up more quickly.
292+
293+
Uses surrogate key purging to attempt to clear ALL pages under /downloads/
294+
in one request, including dynamically added pages like /downloads/android/,
295+
/downloads/ios/, etc. Independently purges a set of specific non-/downloads/
296+
URLs via individual URL purges.
297+
"""
292298
# Don't purge on fixture loads
293299
if kwargs.get("raw", False):
294300
return
295301

296302
# Only purge on published instances
297303
if instance.is_published:
298-
# Purge our common pages
299-
purge_url("/downloads/")
300-
purge_url("/downloads/feed.rss")
301-
purge_url("/downloads/latest/python2/")
302-
purge_url("/downloads/latest/python3/")
303-
# Purge minor version specific URLs (like /downloads/latest/python3.14/)
304-
version = instance.get_version()
305-
if instance.version == Release.PYTHON3 and version:
306-
match = re.match(r"^3\.(\d+)", version)
307-
if match:
308-
purge_url(f"/downloads/latest/python3.{match.group(1)}/")
309-
purge_url("/downloads/latest/prerelease/")
310-
purge_url("/downloads/latest/pymanager/")
311-
purge_url("/downloads/macos/")
312-
purge_url("/downloads/source/")
313-
purge_url("/downloads/windows/")
304+
# Purge all /downloads/* pages via surrogate key (preferred method)
305+
# This catches everything: /downloads/android/, /downloads/release/*, etc.
306+
# Falls back to purge_url if FASTLY_SERVICE_ID is not configured.
307+
if getattr(settings, "FASTLY_SERVICE_ID", None):
308+
purge_surrogate_key("downloads")
309+
else:
310+
purge_url("/downloads/")
311+
312+
# Also purge related pages outside /downloads/
314313
purge_url("/ftp/python/")
315314
if instance.get_version():
316315
purge_url(f"/ftp/python/{instance.get_version()}/")
317-
# See issue #584 for details
316+
# See issue #584 for details - these are under /box/, not /downloads/
318317
purge_url("/box/supernav-python-downloads/")
319318
purge_url("/box/homepage-downloads/")
320319
purge_url("/box/download-sources/")
321-
# Purge the release page itself
322-
purge_url(instance.get_absolute_url())
323320

324321

325322
@receiver(post_save, sender=Release)

fastly/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,30 @@ def purge_url(path):
1919
)
2020

2121
return None
22+
23+
24+
def purge_surrogate_key(key):
25+
"""Purge all Fastly cached content tagged with a surrogate key.
26+
27+
Common keys (set by GlobalSurrogateKey middleware):
28+
- 'pydotorg-app': Purges entire site
29+
- 'downloads': Purges all /downloads/* pages
30+
- 'events': Purges all /events/* pages
31+
- 'sponsors': Purges all /sponsors/* pages
32+
- etc. (first path segment becomes the surrogate key)
33+
34+
Returns the response from Fastly API, or None if not configured.
35+
"""
36+
if settings.DEBUG:
37+
return None
38+
39+
api_key = getattr(settings, "FASTLY_API_KEY", None)
40+
service_id = getattr(settings, "FASTLY_SERVICE_ID", None)
41+
if not api_key or not service_id:
42+
return None
43+
44+
return requests.post(
45+
f"https://api.fastly.com/service/{service_id}/purge/{key}",
46+
headers={"Fastly-Key": api_key},
47+
timeout=30,
48+
)

pydotorg/middleware.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,47 @@ def __call__(self, request):
1919

2020

2121
class GlobalSurrogateKey:
22-
"""Middleware to insert a Surrogate-Key for purging in Fastly or other caches."""
22+
"""Middleware to insert a Surrogate-Key for purging in Fastly or other caches.
23+
24+
Adds both a global key (for full site purges) and section-based keys
25+
derived from the URL path (for targeted purges like /downloads/).
26+
"""
2327

2428
def __init__(self, get_response):
2529
"""Store the get_response callable."""
2630
self.get_response = get_response
2731

32+
def get_section_key(self, path):
33+
"""Extract section surrogate key from URL path.
34+
35+
Examples:
36+
/downloads/ -> downloads
37+
/downloads/release/python-3141/ -> downloads
38+
/events/python-events/ -> events
39+
/ -> None
40+
41+
"""
42+
parts = path.strip("/").split("/")
43+
if parts and parts[0]:
44+
return parts[0]
45+
return None
46+
2847
def __call__(self, request):
29-
"""Append the global surrogate key to the response header."""
48+
"""Append the global and section surrogate keys to the response header."""
3049
response = self.get_response(request)
50+
keys = []
3151
if hasattr(settings, "GLOBAL_SURROGATE_KEY"):
32-
response["Surrogate-Key"] = " ".join(
33-
filter(None, [settings.GLOBAL_SURROGATE_KEY, response.get("Surrogate-Key")])
34-
)
52+
keys.append(settings.GLOBAL_SURROGATE_KEY)
53+
54+
section_key = self.get_section_key(request.path)
55+
if section_key:
56+
keys.append(section_key)
57+
58+
existing = response.get("Surrogate-Key")
59+
if existing:
60+
keys.append(existing)
61+
62+
if keys:
63+
response["Surrogate-Key"] = " ".join(keys)
64+
3565
return response

pydotorg/settings/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@
265265
### Fastly ###
266266
FASTLY_API_KEY = False # Set to Fastly API key in production to allow pages to
267267
# be purged on save
268+
FASTLY_SERVICE_ID = config("FASTLY_SERVICE_ID", default=None) # Required for surrogate key purging
268269

269270
# Jobs
270271
JOB_THRESHOLD_DAYS = 90

pydotorg/tests/test_middleware.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.contrib.redirects.models import Redirect
22
from django.contrib.sites.models import Site
3-
from django.test import TestCase
3+
from django.test import TestCase, override_settings
4+
5+
from pydotorg.middleware import GlobalSurrogateKey
46

57

68
class MiddlewareTests(TestCase):
@@ -21,3 +23,39 @@ def test_redirects(self):
2123
response = self.client.get(url)
2224
self.assertEqual(response.status_code, 301)
2325
self.assertEqual(response["Location"], redirect.new_path)
26+
27+
28+
class GlobalSurrogateKeyTests(TestCase):
29+
def test_get_section_key(self):
30+
"""Test section key extraction from URL paths."""
31+
middleware = GlobalSurrogateKey(lambda _: None)
32+
33+
self.assertEqual(middleware.get_section_key("/downloads/"), "downloads")
34+
self.assertEqual(middleware.get_section_key("/downloads/release/python-3141/"), "downloads")
35+
self.assertEqual(middleware.get_section_key("/events/"), "events")
36+
self.assertEqual(middleware.get_section_key("/events/python-events/123/"), "events")
37+
self.assertEqual(middleware.get_section_key("/sponsors/"), "sponsors")
38+
39+
# returns None
40+
self.assertIsNone(middleware.get_section_key("/"))
41+
42+
self.assertEqual(middleware.get_section_key("/downloads"), "downloads")
43+
self.assertEqual(middleware.get_section_key("downloads/"), "downloads")
44+
45+
@override_settings(GLOBAL_SURROGATE_KEY="pydotorg-app")
46+
def test_surrogate_key_header_includes_section(self):
47+
"""Test that Surrogate-Key header includes both global and section keys."""
48+
response = self.client.get("/downloads/")
49+
self.assertTrue(response.has_header("Surrogate-Key"))
50+
surrogate_key = response["Surrogate-Key"]
51+
52+
self.assertIn("pydotorg-app", surrogate_key)
53+
self.assertIn("downloads", surrogate_key)
54+
55+
@override_settings(GLOBAL_SURROGATE_KEY="pydotorg-app")
56+
def test_surrogate_key_header_homepage(self):
57+
"""Test that homepage only has global surrogate key."""
58+
response = self.client.get("/")
59+
self.assertTrue(response.has_header("Surrogate-Key"))
60+
surrogate_key = response["Surrogate-Key"]
61+
self.assertEqual(surrogate_key, "pydotorg-app")

0 commit comments

Comments
 (0)