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' }}">
|
||||
```
|
||||
|
||||
`deployment_timestamp` comes from `core/context_processors.py::project_context` as
|
||||
`int(time.time())` — meaning every Django request generates a new query-string value.
|
||||
Cloudflare treats each new `?v=...` value as a new URL → `cf-cache-status: MISS` →
|
||||
fresh fetch from the VM. Users always see the latest CSS as soon as the Django
|
||||
process restarts.
|
||||
|
||||
**Trade-off**: because the timestamp changes every second, CDN cache-hit rate on
|
||||
CSS is effectively zero. For a low-traffic app this is fine. If traffic grows,
|
||||
consider switching to a file-mtime-based token so the URL only changes when the
|
||||
CSS actually changes.
|
||||
`deployment_timestamp` comes from `core/context_processors.py::project_context`
|
||||
via `_compute_cache_bust_token()` — returns the mtime of
|
||||
`static/css/custom.css` as an integer. The token only changes when the
|
||||
CSS file is modified, so Cloudflare's edge cache holds each version for
|
||||
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
|
||||
mtime → new token → cache busts. Pre-24-Apr-2026 this was
|
||||
`int(time.time())` per-request, which defeated the CDN cache entirely
|
||||
(effectively 0% hit rate on CSS). Degraded-mode fallback: if
|
||||
`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
|
||||
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 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):
|
||||
"""
|
||||
Adds project-specific environment variables to the template context globally.
|
||||
"""
|
||||
"""Adds project-specific environment variables to the template context globally."""
|
||||
return {
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
# Used for cache-busting static assets
|
||||
"deployment_timestamp": int(time.time()),
|
||||
# Cache-busts static assets — see _compute_cache_bust_token().
|
||||
"deployment_timestamp": _compute_cache_bust_token(),
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
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.team2.id, self.w1.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