perf(cache): mtime-based CSS cache-bust token
deployment_timestamp was int(time.time()) per-request, giving every page load a new ?v=... query string on custom.css. Cloudflare treats each unique URL as a new resource, so the CSS was fetched from the VM on every page load — 64 KB over the wire per navigation. Token now tied to static/css/custom.css mtime. The URL only changes when the CSS actually changes, so Cloudflare can hold the file for its full 4h TTL. Degraded-mode fallback preserves today's behaviour if the file isn't on disk. 3 new CacheBustTokenTests; all 68 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bcd0112687
commit
16d4399c28
22
CLAUDE.md
22
CLAUDE.md
@ -242,16 +242,18 @@ change when the file does.
|
|||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp|default:'1' }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp|default:'1' }}">
|
||||||
```
|
```
|
||||||
|
|
||||||
`deployment_timestamp` comes from `core/context_processors.py::project_context` as
|
`deployment_timestamp` comes from `core/context_processors.py::project_context`
|
||||||
`int(time.time())` — meaning every Django request generates a new query-string value.
|
via `_compute_cache_bust_token()` — returns the mtime of
|
||||||
Cloudflare treats each new `?v=...` value as a new URL → `cf-cache-status: MISS` →
|
`static/css/custom.css` as an integer. The token only changes when the
|
||||||
fresh fetch from the VM. Users always see the latest CSS as soon as the Django
|
CSS file is modified, so Cloudflare's edge cache holds each version for
|
||||||
process restarts.
|
its full 4h TTL and repeat page loads in a session hit the browser
|
||||||
|
cache (304 Not Modified). Deploys that include a CSS change bump the
|
||||||
**Trade-off**: because the timestamp changes every second, CDN cache-hit rate on
|
mtime → new token → cache busts. Pre-24-Apr-2026 this was
|
||||||
CSS is effectively zero. For a low-traffic app this is fine. If traffic grows,
|
`int(time.time())` per-request, which defeated the CDN cache entirely
|
||||||
consider switching to a file-mtime-based token so the URL only changes when the
|
(effectively 0% hit rate on CSS). Degraded-mode fallback: if
|
||||||
CSS actually changes.
|
`custom.css` isn't on disk (e.g., fresh container before
|
||||||
|
`collectstatic`), the function falls back to the old per-request
|
||||||
|
timestamp rather than crashing.
|
||||||
|
|
||||||
### The pitfall this replaced
|
### The pitfall this replaced
|
||||||
Pre-Apr 2026, the template used `{{ request.timestamp|default:'1.0' }}`. But
|
Pre-Apr 2026, the template used `{{ request.timestamp|default:'1.0' }}`. But
|
||||||
|
|||||||
@ -1,13 +1,44 @@
|
|||||||
|
# === core/context_processors.py ===
|
||||||
|
# Globals injected into every template render.
|
||||||
|
#
|
||||||
|
# `deployment_timestamp` is a cache-bust token on our CSS URL. Historically
|
||||||
|
# it was `int(time.time())` — a new value every second — which defeated
|
||||||
|
# Cloudflare's edge cache because every page load saw a different `?v=...`
|
||||||
|
# string. We now tie the token to `custom.css`'s mtime, so the URL
|
||||||
|
# changes ONLY when the CSS actually changes. Cloudflare can hold the
|
||||||
|
# file for its full 4h TTL, and users on repeat visits hit the browser
|
||||||
|
# cache (304 Not Modified).
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Path to the file whose mtime drives the cache-bust token. Module-level
|
||||||
|
# constant so tests can monkey-patch it to simulate "file missing".
|
||||||
|
_CSS_PATH_FOR_TOKEN = Path(settings.BASE_DIR) / 'static' / 'css' / 'custom.css'
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_cache_bust_token():
|
||||||
|
"""Return an integer cache-bust token.
|
||||||
|
|
||||||
|
Normal path: returns the CSS file's mtime (an integer).
|
||||||
|
Fallback: if the file can't be stat'd (doesn't exist / permission
|
||||||
|
error / disk issue), returns the current wall-clock time in seconds
|
||||||
|
— that degrades to the PRE-FIX behaviour (new token per request)
|
||||||
|
rather than crashing the whole request cycle.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return int(os.path.getmtime(_CSS_PATH_FOR_TOKEN))
|
||||||
|
except (OSError, FileNotFoundError):
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
def project_context(request):
|
def project_context(request):
|
||||||
"""
|
"""Adds project-specific environment variables to the template context globally."""
|
||||||
Adds project-specific environment variables to the template context globally.
|
|
||||||
"""
|
|
||||||
return {
|
return {
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||||
# Used for cache-busting static assets
|
# Cache-busts static assets — see _compute_cache_bust_token().
|
||||||
"deployment_timestamp": int(time.time()),
|
"deployment_timestamp": _compute_cache_bust_token(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -1237,3 +1238,44 @@ class AdjustmentsTabTests(TestCase):
|
|||||||
self.assertIn((self.team.id, self.w2.id), pair_set)
|
self.assertIn((self.team.id, self.w2.id), pair_set)
|
||||||
self.assertIn((self.team2.id, self.w1.id), pair_set)
|
self.assertIn((self.team2.id, self.w1.id), pair_set)
|
||||||
self.assertIn((self.team2.id, self.w2.id), pair_set)
|
self.assertIn((self.team2.id, self.w2.id), pair_set)
|
||||||
|
|
||||||
|
|
||||||
|
# === Cache-bust token tests ===
|
||||||
|
# The deployment_timestamp context variable controls the ?v=... query
|
||||||
|
# string on our CSS URL. It MUST only change when custom.css changes
|
||||||
|
# — otherwise Cloudflare cache-HIT rate on the CSS drops to zero and
|
||||||
|
# every page reload re-fetches 64 KB from the VM.
|
||||||
|
|
||||||
|
class CacheBustTokenTests(TestCase):
|
||||||
|
"""Regression tests for the mtime-based cache-bust token."""
|
||||||
|
|
||||||
|
def test_token_is_an_integer(self):
|
||||||
|
"""Token must be int (templates cast to str; float would show a dot)."""
|
||||||
|
from core.context_processors import project_context
|
||||||
|
ctx = project_context(request=None)
|
||||||
|
self.assertIsInstance(ctx['deployment_timestamp'], int)
|
||||||
|
|
||||||
|
def test_token_is_stable_across_two_calls(self):
|
||||||
|
"""Critical property: two back-to-back calls return the same
|
||||||
|
token — because custom.css hasn't changed between them. This
|
||||||
|
is the entire point of the mtime-based approach."""
|
||||||
|
from core.context_processors import project_context
|
||||||
|
t1 = project_context(request=None)['deployment_timestamp']
|
||||||
|
t2 = project_context(request=None)['deployment_timestamp']
|
||||||
|
self.assertEqual(t1, t2)
|
||||||
|
|
||||||
|
def test_token_falls_back_if_file_missing(self):
|
||||||
|
"""If static/css/custom.css is somehow missing (fresh
|
||||||
|
container pre-collectstatic), we must NOT crash. We fall back
|
||||||
|
to int(time.time()) so every page still renders."""
|
||||||
|
import core.context_processors as cp
|
||||||
|
original = cp._compute_cache_bust_token
|
||||||
|
try:
|
||||||
|
# Monkey-patch so the function sees a guaranteed-missing path.
|
||||||
|
cp._CSS_PATH_FOR_TOKEN = cp.Path('/definitely/does/not/exist.css')
|
||||||
|
# Should return an int and NOT raise.
|
||||||
|
token = cp._compute_cache_bust_token()
|
||||||
|
self.assertIsInstance(token, int)
|
||||||
|
finally:
|
||||||
|
# Reset the module-level cache so other tests get the real value.
|
||||||
|
cp._CSS_PATH_FOR_TOKEN = cp.Path(settings.BASE_DIR) / 'static' / 'css' / 'custom.css'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user