From c842c445658de4057e2506219f9d11a6faf94de5 Mon Sep 17 00:00:00 2001 From: Armin Samii Date: Tue, 13 Oct 2026 16:52:57 -0400 Subject: [PATCH] cache headers respected updates but not deploys. fix. --- rcvis/settings.py | 15 +++++++++++++++ visualizer/tests/testSimple.py | 34 ++++++++++++++++++++++++++++++++++ visualizer/views.py | 32 ++++++++++++++++++++++---------- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/rcvis/settings.py b/rcvis/settings.py index dfb5b5c1..ad5c01fd 100644 --- a/rcvis/settings.py +++ b/rcvis/settings.py @@ -11,6 +11,8 @@ """ import os +import time +from datetime import datetime import django_on_heroku @@ -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') diff --git a/visualizer/tests/testSimple.py b/visualizer/tests/testSimple.py index 64e75bce..1af034cd 100644 --- a/visualizer/tests/testSimple.py +++ b/visualizer/tests/testSimple.py @@ -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 diff --git a/visualizer/views.py b/visualizer/views.py index e73dac35..d970fa7c 100644 --- a/visualizer/views.py +++ b/visualizer/views.py @@ -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 @@ -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) @@ -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