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' }}">
```
`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

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

View File

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