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:
Konrad du Plessis 2026-04-24 00:31:05 +02:00
parent bcd0112687
commit 16d4399c28
3 changed files with 90 additions and 15 deletions

View File

@ -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

View File

@ -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(),
} }

View File

@ -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'