Skip to content

Cloudflare is caching nothing; restore the deliberate Cache-Control on pass-through views#17

Description

@davmlaw

馃 Written by Claude

(Body revised after checking git history. The original framing led with "add a CACHES setting", which is the least valuable fix here. See the comment below.)

Background: this was a deliberate decision, then lost in a refactor

In 57ea4eb "Add cache control" (Jan 2022) the transcript view carried this comment:

# I don't think there's much point caching this as it just fetches it out of Redis anyway

and instead of a server-side page cache it set a downstream header:

max_age = getattr(settings, 'CACHE_CONTROL_MAX_AGE', 2592000)  # 30 days
response['Cache-Control'] = 'max-age=%d' % max_age

That is the right instinct for this view. The single-accession path is a pure pass-through (r.get() straight into HttpResponse, cdot_rest/views.py:115-119), so server-side caching buys close to nothing, while downstream caching keeps requests off the box entirely.

In be8124e (Sep 2022, the #3/#4 work) transcripts_for_gene and transcripts_for_region were added. Those go through RedisDataProvider and do real work, so @cache_page on them is reasonable. But in the same commit @cache_page(DAY_SECONDS) was also applied to transcript, the comment was deleted, and the Cache-Control header was removed. That looks like collateral from the _get_redis() extraction rather than a reversal of the earlier decision.

Current state

The header was downgraded, not lost. cache_page calls django.utils.cache.patch_response_headers, which sets Cache-Control and Expires as a side effect. Production currently sends:

cache-control: max-age=86400

So 1 day, versus the 30 days deliberately chosen in 2022. CACHE_CONTROL_MAX_AGE no longer exists in settings.py and there is no Cache-Control anywhere in views.py or config/nginx.conf; the header is purely a by-product of the decorator now.

The server-side half is doing nothing useful. settings.py has no CACHES block, so Django falls back to its global_settings default of LocMemCache, which is per-process with MAX_ENTRIES = 300 and CULL_FREQUENCY = 3 (verified against the pinned Django 6.0.6). gunicorn runs -w 8 (config/gunicorn.service:9), so that is 8 unshared 300-entry caches against 1,835,279 stored transcript versions. The hit rate for by-accession traffic is effectively nil.

The actual opportunity: Cloudflare is in front and caching nothing

The site already sits behind Cloudflare (server: cloudflare, cf-ray present). Every endpoint returns cf-cache-status: DYNAMIC, meaning the edge is not caching and every request reaches origin:

/static/openapi.yaml       cf-cache-status: DYNAMIC
/llms.txt                  cf-cache-status: DYNAMIC
/transcript/NM_007294.4    cf-cache-status: DYNAMIC   (cache-control: max-age=86400)
/gene/BRCA2                cf-cache-status: DYNAMIC   (cache-control: max-age=86400)

Cloudflare caches only a fixed list of file extensions by default. /transcript/NM_007294.4 is not an eligible extension, so the origin's Cache-Control is ignored for edge caching purposes no matter what value it has.

Suggested changes, in order of value

1. Add a Cloudflare Cache Rule ("Cache Everything") on the read paths. Config change, no code. This is where nearly all the benefit is: it would put the by-accession responses on Cloudflare's edge and take the bulk of the load off gunicorn and Redis. Worth also covering /static/ and /llms.txt, which are genuinely static and currently uncached.

2. Restore an explicit, longer Cache-Control. Bring back something like CACHE_CONTROL_MAX_AGE rather than inheriting whatever cache_page's timeout happens to be, so the value is a decision again instead of a side effect. Transcript data for a given versioned accession is immutable in practice between data releases, so the original 30 days was defensible.

3. Drop @cache_page from transcript and gene, and set the header directly. This restores the 2022 decision. Note that if the decorator is removed, the Cache-Control/Expires headers go with it, so (2) has to happen at the same time or the headers disappear entirely.

4. Only then consider a shared CACHES backend, and only for the computed endpoints (transcripts_for_region, mane_transcripts_for_gene, transcripts_tags_for_gene) where there is real per-request work: unpickling a per-contig IntervalTree and running cdot's ranking logic. If a Redis backend is used, put it on a different db from the data (eg db 1), because import_transcript_json --clear runs flushdb on db 0 (cdot_json/management/commands/import_transcript_json.py:43-45) and would otherwise wipe the cache on every reload.

5. Minor: _get_redis() (cdot_rest/views.py:22-23) builds a fresh redis.Redis(...) per request rather than reusing a module-level client, so the redis-py connection pool is discarded each time.

Out of scope

The batch endpoint transcripts is POST-only and uncached by nature (cdot_rest/views.py:122-124). Noting it only so it is explicitly excluded.

Measurement

Before and after, it would help to have the endpoint mix from the nginx access logs, and cf-cache-status hit rates once (1) is in place. There is currently no request-volume visibility anywhere in the repo, and that same data decides #18.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions