Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions rcvis/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"""

import os
import time
from datetime import datetime

import django_on_heroku

Expand Down Expand Up @@ -273,6 +275,19 @@
HEROKU_APP_NAME = os.environ.get('HEROKU_APP_NAME')
HEROKU_WORKER_DYNO_TYPE = os.environ.get('HEROKU_WORKER_DYNO_TYPE')

# When the current code was deployed, used as a floor for Last-Modified on
# visualization pages: django-compressor asset filenames change on deploy and
# the old files no longer exist, so HTML cached before a deploy must revalidate
# as stale even when the underlying model hasn't changed.
# HEROKU_RELEASE_CREATED_AT (e.g. "2026-10-01T00:08:14Z") requires the
# runtime-dyno-metadata labs feature; without it, and locally, fall back to
# process start time.
try:
DEPLOY_TIMESTAMP = datetime.fromisoformat(
os.environ['HEROKU_RELEASE_CREATED_AT']).timestamp()
except (KeyError, ValueError):
DEPLOY_TIMESTAMP = time.time()

# Movie creation
AWS_POLLY_STORAGE_BUCKET_NAME = os.environ.get('AWS_POLLY_STORAGE_BUCKET_NAME')

Expand Down
34 changes: 34 additions & 0 deletions visualizer/tests/testSimple.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,40 @@ def test_conditional_get_returns_200_after_update(self):
# And the new Last-Modified should differ
self.assertNotEqual(response2['Last-Modified'], oldLastModified)

def test_conditional_get_returns_200_after_deploy(self):
"""
A deploy changes the hashed static asset filenames the HTML points at,
so even an unchanged model must revalidate as stale: with a deploy
newer than both updatedAt and the client's copy, the old
If-Modified-Since should get a fresh 200 whose Last-Modified is the
deploy time, not updatedAt.
"""
with open(filenames.ONE_ROUND, 'r', encoding='utf-8') as f:
self.client.post('/upload.html', {'jsonFile': f})
config = TestHelpers.get_latest_upload()
path = reverse('visualize', args=(config.slug,))

with self.settings(CACHES={'default': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}}):
# Client cached the page when the model was last updated
clientCopyDate = http_date(config.updatedAt.timestamp())

# Pre-deploy, that copy is still fresh
response1 = self.client.get(path, HTTP_IF_MODIFIED_SINCE=clientCopyDate)
self.assertEqual(response1.status_code, 304)

# Simulate a deploy after the client cached the page
deployTimestamp = config.updatedAt.timestamp() + 100
with self.settings(DEPLOY_TIMESTAMP=deployTimestamp):
response2 = self.client.get(path, HTTP_IF_MODIFIED_SINCE=clientCopyDate)
self.assertEqual(response2.status_code, 200)
self.assertEqual(response2['Last-Modified'], http_date(deployTimestamp))

# Once the client has the post-deploy copy, 304s resume
response3 = self.client.get(
path, HTTP_IF_MODIFIED_SINCE=response2['Last-Modified'])
self.assertEqual(response3.status_code, 304)

def test_response_has_last_modified_header(self):
"""
Visualization responses should include a Last-Modified header
Expand Down
32 changes: 22 additions & 10 deletions visualizer/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,18 @@ class ConditionalGetMixin: # pylint: disable=too-few-public-methods
"""
Mixin for DetailView subclasses that serve JsonConfig visualizations.

Sets Last-Modified from the object's updatedAt so clients can
revalidate cheaply. On cache misses (file cache empty), short-circuits
with 304 if the client already has a fresh copy, avoiding expensive
graph computation. On cache hits, Django's ConditionalGetMiddleware
handles the 304 conversion using the Last-Modified header preserved
in the cached response, without touching the database.
Sets Last-Modified from the object's updatedAt, floored at the deploy
time, so clients can revalidate cheaply. The deploy floor matters:
the rendered HTML references hashed django-compressor assets that
change on deploy, so HTML cached before a deploy must revalidate as
stale even when the model itself hasn't changed — otherwise browsers
keep 304-renewing HTML that points at deleted static files.

On cache misses (file cache empty), short-circuits with 304 if the
client already has a fresh copy, avoiding expensive graph computation.
On cache hits, Django's ConditionalGetMiddleware handles the 304
conversion using the Last-Modified header preserved in the cached
response, without touching the database.

Browser and edge cache lifetimes are owned by Cloudflare, which
ignores the origin's Cache-Control for these pages and is purged
Expand All @@ -184,17 +190,23 @@ class ConditionalGetMixin: # pylint: disable=too-few-public-methods
store the rendered response in the server-side file cache.
"""

def _last_modified_timestamp(self):
"""The object's updatedAt floored at the deploy time, or None."""
if not self.object.updatedAt:
return None
return max(self.object.updatedAt.timestamp(), settings.DEPLOY_TIMESTAMP)

def get(self, request, *args, **kwargs):
"""Return 304 if the client's copy is fresh, otherwise render normally."""
# Fetch object once — setting self.object avoids a second DB query
# when super().get() calls get_object() internally.
self.object = self.get_object()
lastModified = self._last_modified_timestamp()

# Short-circuit: if the client has a fresh copy, return 304 without
# doing any of the expensive graph computation or template rendering.
# This handles cache misses where FetchFromCacheMiddleware found nothing.
if self.object.updatedAt:
lastModified = self.object.updatedAt.timestamp()
if lastModified:
ifModifiedSince = request.META.get('HTTP_IF_MODIFIED_SINCE')
if ifModifiedSince:
ifModifiedSince = parse_http_date_safe(ifModifiedSince)
Expand All @@ -204,8 +216,8 @@ def get(self, request, *args, **kwargs):
return response

response = super().get(request, *args, **kwargs)
if self.object.updatedAt:
response['Last-Modified'] = http_date(self.object.updatedAt.timestamp())
if lastModified:
response['Last-Modified'] = http_date(lastModified)
return response


Expand Down
Loading