Skip to content

Fix server-side cache to work with conditional GET - #608

Merged
13 commits merged into
artoonie:mainfrom
skaphan:fix-server-cache
Mar 19, 2026
Merged

Fix server-side cache to work with conditional GET#608
13 commits merged into
artoonie:mainfrom
skaphan:fix-server-cache

Conversation

@skaphan

Copy link
Copy Markdown
Contributor

Summary

  • Removes max_age=0 from patch_cache_control in ConditionalGetMixin -- this was preventing UpdateCacheMiddleware from storing responses server-side, since it checks get_max_age(response) and skips caching when the result is 0.
  • Replaces cache.clear() with per-URL cache purging in cloudflare.py -- uses get_cache_key() with synthetic HttpRequest objects to surgically invalidate only the affected visualization URLs from the file-based cache.
  • Removes DISABLE_CACHE toggle from settings -- the file-based cache is now always enabled and works correctly with conditional GET.
  • Removes vary_on_headers(increment) -- this was a no-op since no client ever sends that header.

How it works

  1. ConditionalGetMixin sets Cache-Control: no-cache (browsers revalidate on every request)
  2. UpdateCacheMiddleware stores the rendered response in Django file-based cache (now works because max_age is not 0)
  3. FetchFromCacheMiddleware serves cached responses on subsequent requests
  4. ConditionalGetMiddleware compares Last-Modified vs If-Modified-Since and returns 304 when content has not changed
  5. On model update, CloudflareAPI.purge_vis_cache() clears both Cloudflare CDN and the specific Django cache entries

Builds on #594 (proper cache control).

skaphanand others added 5 commits February 24, 2026 13:29
Add updated_at field to JsonConfig model, use ConditionalGetMixin for
all visualization views, and short-circuit 304 responses in
VisualizeEmbedded before expensive computation.
Add proper cache control for embedded visualizations
- Add pylint disable for too-few-public-methods (it is a mixin)
- Add docstring to get() method
- Rename last_modified/if_modified_since to camelCase
Co-Authored-By: Claude Opus 4.6
- Remove max_age=0 from ConditionalGetMixin so UpdateCacheMiddleware
can store rendered responses in the file cache. Browsers still
revalidate via Cache-Control: no-cache.
- Replace cache.clear() sledgehammer in cloudflare.py with per-URL
cache purging using get_cache_key — only the updated slug's pages
are evicted from Django's file cache.
- Remove vary_on_headers('increment') — was a no-op (no client sends
the header).
- Remove DISABLE_CACHE env toggle — no longer needed.
- ConditionalGetMiddleware (already in middleware stack) handles 304
responses for cached pages using the Last-Modified header.
Co-Authored-By: Claude Opus 4.6

@artoonieartoonie left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! This looks pretty close to ready. I recommend reverting the changes to purging django cache and DISABLE_CACHE for now, as that would be easier than fixing them.

Comment threadcommon/cloudflare.py Outdated
request.path = path
request.META['QUERY_STRING'] = ''
request.META['SERVER_NAME'] = 'localhost'
request.META['SERVER_PORT'] = '80'

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will work in development but not in production -- we are missing HTTP_HOST (which could be rcvis.com or www.rcvis.com), url scheme (https), and maybe others.

I think the only way to get this to work is to store a local map of slugs to cache IDs and to use that to clear the cache -- manually building the cache key is brittle.

Comment threadrcvis/settings.py

AWS_DEFAULT_ACL = None

if os.environ.get('DISABLE_CACHE') != 'True':

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If removing this, we should also remove it from infra/.env.template.

However -- during development, it's still useful to be able to disable cache. Not all cache invalidations come from PATCHes. If you update an HTML or JS file locally, you want to be able to refresh the page without restarting the server. It doesn't always work (because JS files are minified and templates are compiled), but there are cases when it does.

Comment threadscripts/reset-db.sh Outdated
print('API user skaphan already exists')
"

echo "Database reset complete."

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel comfortable committing this to the RCVis mainline -- a database deletion script feels like a high-risk script to leave in production.

@skaphan

skaphan commented Mar 6, 2026 via email

Copy link
Copy Markdown
ContributorAuthor

- _purge_django_cache now uses Site.objects.get_current().domain to
construct requests with the correct HTTP_HOST and HTTPS scheme,
matching how UpdateCacheMiddleware stores cache keys in production.
Tries both primary domain and www. variant.
- Extracted _make_cache_request helper for constructing synthetic requests.
- Restored DISABLE_CACHE env var toggle for development convenience.
- Removed reset-db.sh (not appropriate for mainline).
- Added three tests for django cache purging: basic path, query string,
and www. variant coverage.
Co-Authored-By: Claude Opus 4.6
@skaphan

Copy link
Copy Markdown
ContributorAuthor

Thanks for the review! I've pushed changes addressing all three points:

Cache purging: You're right that the synthetic request was missing production-level headers. I've fixed _purge_django_cache to use Site.objects.get_current().domain for the HTTP_HOST (the same source get_absolute_paths_for already uses for Cloudflare purges) and set wsgi.url_scheme to https. It also tries both the primary domain and the www. variant, mirroring the Cloudflare purge logic. I extracted a _make_cache_request helper to keep it testable and added three tests covering basic paths, query strings, and the www variant.

Without per-URL purging, the cache would serve stale responses after a PATCH — FetchFromCacheMiddleware returns the old cached page before ConditionalGetMixin ever runs, so the Last-Modified check never happens. If you'd prefer to revert to cache.clear() as a simpler fallback, I'm happy to do that instead.

DISABLE_CACHE: Restored.

reset-db.sh: Removed.

@skaphan

Copy link
Copy Markdown
ContributorAuthor

Added 5 more tests covering the server-side cache paths:

  • test_conditional_get_returns_304_when_fresh -- verifies ConditionalGetMixin returns 304 when client If-Modified-Since is current
  • test_conditional_get_returns_200_after_update -- verifies stale If-Modified-Since gets a fresh 200 after model update
  • test_response_has_last_modified_header -- checks both Visualize and VisualizeEmbedded set Last-Modified matching updatedAt
  • test_response_has_no_cache_directive -- confirms Cache-Control: no-cache on responses
  • test_save_purge_only_on_update -- verifies purge_vis_cache is called on model update (not just creation)

Total cache-related tests on this branch: 8 (3 purge + 5 cache path).

Test 304 when If-Modified-Since is later than Last-Modified, and
200 (from cache) when If-Modified-Since is earlier than Last-Modified.
Co-Authored-By: Claude Opus 4.6

@artoonieartoonie left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Shel, thanks for the long PR! I'm almost done reviewing but wanted to back up for a bit:

This PR is trying to fix two issues, (1) fixing the 304, and (2) avoiding clearing all local cache. Perhaps we should separate them, as (1) is the crucial issue and (2) is a nice optimization.

Most of the concerns I have here are about the second issue, but I'd love to get the first out the door.

Apologies for how long the review cycle is taking. I try to prioritize reviewing PRs quickly but I have been falling behind here.

Comment threadvisualizer/tests/testSimple.py Outdated

# Second request should be served from cache (still 200)
response2 = self.client.get(path)
self.assertEqual(response2.status_code, 200)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help me understand this? I don't see how this test shows it's served from cache.

Comment threadvisualizer/tests/testSimple.py Outdated
self.assertEqual(response2.status_code, 200)

# Purge and verify the cache entry is gone by checking that
# a new request still works (no 304 from stale cache)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't checked 304 anywhere else in this function, I'm not sure what this is referring to?

Comment threadcommon/cloudflare.py Outdated
# This is safe because we're constructing internal requests, not
# processing user input.
original_hosts = settings.ALLOWED_HOSTS
settings.ALLOWED_HOSTS = list(set(original_hosts + domains))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a small race condition where the allowed_hosts is changing. I have never seen Django code modify settings on-the-fly, especially not security settings. While this code looks safe, it raises red flags for me. Django settings are usually immutable after startup.

Race condition 1: Thread A modifies; Thread B modifies; Thread A restores; Thread B restores. In this case, Thread B will have restored to the modified value.
Race condition 2: Thread A modifies; external actor exploits; Thread B restores

Is the only thing this piece of code is supporting allowing both the www and non-www variant of the URL to be used?

It seems this code would also limit the site to only being accessible from two URLs (x.com and www.x.com), whereas there are many cases where that does not hold (e.g. Heroku Review Apps) and cases where it limits future behavior (like if rcviz.com wants to mirror instead of redirect).

Comment threadvisualizer/tests/testSimple.py Outdated
self.client.get(path_with_qs)

# Purge should not raise, even with query string
CloudflareAPI._purge_django_cache([path_with_qs])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tests that it doesn't crash, but not that the query string is respected

Comment threadvisualizer/tests/testSimple.py Outdated
for host in [domain, f'www.{domain}']:
request = CloudflareAPI._make_cache_request(path, host)
cache_key = get_cache_key(request)
if cache_key:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we also assert cache_key is not none?

Comment threadvisualizer/tests/testSimple.py Outdated
with self.settings(CACHES={'default': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}}):
# First request: get the Last-Modified header
response1 = self.client.get(path)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a fair head-to-head test comparison, the first request should also include last_modified

Comment threadvisualizer/tests/testSimple.py
Comment threadvisualizer/tests/testSimple.py
UpdateCacheMiddleware appended max-age=600 to responses with no-cache,
telling browsers not to revalidate for 10 minutes. Custom subclass
strips max-age when no-cache is set. Also fix cache purge to use the
actual request host (via thread-local) so keys match in dev and prod.
Co-Authored-By: Claude Opus 4.6
@skaphan

Copy link
Copy Markdown
ContributorAuthor

Addendum: browser cache fix (a1279e4)

Problem

After PATCHing a visualization (e.g. re-tabulating with an excluded candidate), the iframe showed stale content. Three issues were at play:

  1. max-age=600 leaked into browser responses. Django's UpdateCacheMiddleware stores responses in the server-side file cache (good -- avoids expensive graph recomputation) but also appends max-age=600 to Cache-Control. Combined with our no-cache, this told browsers not to revalidate for 10 minutes.

  2. Cache purge missed in dev. _purge_django_cache built synthetic requests using Site.objects.get_current().domain (e.g. example.com), but dev requests arrive on localhost:8000. The cache keys did not match, so purge was a no-op locally.

  3. 304 after no-change PATCH (not a bug). After fixing Outgoing node lines don't line up #1 and Add election date to heading #2, we still saw 304s when PATCHing with identical data. This turned out to be correct: Django's ConditionalGetMiddleware computes an ETag from the response body. Same data = same HTML = same ETag = 304. Real data changes produce different HTML = different ETag = 200.

Solution

  • UpdateCacheWithoutMaxAgeMiddleware: subclass that calls super() (stores in server cache), then strips max-age from responses that have no-cache. Browser always revalidates; server cache stays warm.
  • CurrentRequestMiddleware: stores the current request in a thread-local. _purge_django_cache reads it to construct synthetic requests with the correct host, so cache keys match in both dev and production.
  • ConditionalGetMixin comment updated to reflect the middleware change.

Revert to upstream cache.clear() approach for Django file cache.
The surgical purge optimization is deferred to a separate PR.
Remove CurrentRequestMiddleware and related purge tests.
Co-Authored-By: Claude Opus 4.6
@skaphan

Copy link
Copy Markdown
ContributorAuthor

Addressed by simplifying: the surgical cache purge code (with its ALLOWED_HOSTS manipulation and thread-local middleware) has been removed from this PR. purge_paths_cache now uses cache.clear() — a straightforward full cache clear that doesn't need to reconstruct cache keys or worry about host matching.

The surgical purge optimization is preserved on a separate branch (surgical-cache-purge) for a potential follow-up PR, where the ALLOWED_HOSTS concern and other refinements can be addressed independently.

Strengthen conditional GET tests with old-date baselines for fair
comparison, assertNumQueries(0) to prove middleware-cached 304 never
hits the view, and cross-referencing docstrings. Add test verifying
REST PATCH advances updatedAt.
Co-Authored-By: Claude Opus 4.6
@skaphanskaphan mentioned this pull request Mar 11, 2026
2 tasks
@artoonieartoonie closed this pull request by merging all changes into artoonie:main in b69eb3dMar 19, 2026
@skaphan
skaphan deleted the fix-server-cache branch March 20, 2026 19:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@skaphan@artoonie