馃 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.
馃 Written by Claude
(Body revised after checking git history. The original framing led with "add a
CACHESsetting", 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) thetranscriptview carried this comment:# I don't think there's much point caching this as it just fetches it out of Redis anywayand instead of a server-side page cache it set a downstream header:
That is the right instinct for this view. The single-accession path is a pure pass-through (
r.get()straight intoHttpResponse,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_geneandtranscripts_for_regionwere added. Those go throughRedisDataProviderand do real work, so@cache_pageon them is reasonable. But in the same commit@cache_page(DAY_SECONDS)was also applied totranscript, the comment was deleted, and theCache-Controlheader 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_pagecallsdjango.utils.cache.patch_response_headers, which setsCache-ControlandExpiresas a side effect. Production currently sends:So 1 day, versus the 30 days deliberately chosen in 2022.
CACHE_CONTROL_MAX_AGEno longer exists insettings.pyand there is noCache-Controlanywhere inviews.pyorconfig/nginx.conf; the header is purely a by-product of the decorator now.The server-side half is doing nothing useful.
settings.pyhas noCACHESblock, so Django falls back to itsglobal_settingsdefault ofLocMemCache, which is per-process withMAX_ENTRIES = 300andCULL_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-raypresent). Every endpoint returnscf-cache-status: DYNAMIC, meaning the edge is not caching and every request reaches origin:Cloudflare caches only a fixed list of file extensions by default.
/transcript/NM_007294.4is not an eligible extension, so the origin'sCache-Controlis 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 likeCACHE_CONTROL_MAX_AGErather than inheriting whatevercache_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_pagefromtranscriptandgene, and set the header directly. This restores the 2022 decision. Note that if the decorator is removed, theCache-Control/Expiresheaders go with it, so (2) has to happen at the same time or the headers disappear entirely.4. Only then consider a shared
CACHESbackend, 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-contigIntervalTreeand running cdot's ranking logic. If a Redis backend is used, put it on a different db from the data (eg db 1), becauseimport_transcript_json --clearrunsflushdbon 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 freshredis.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
transcriptsis 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-statushit rates once (1) is in place. There is currently no request-volume visibility anywhere in the repo, and that same data decides #18.