Skip to content

Add Redis-backed response caching to the API - #603

Merged
bpepple merged 5 commits into
masterfrom
api-cache
Aug 20, 2026
Merged

Add Redis-backed response caching to the API#603
bpepple merged 5 commits into
masterfrom
api-cache

Conversation

@bpepple

@bpepple bpepple commented Aug 20, 2026

Copy link
Copy Markdown
Member

Description

Adds Redis-backed response caching to the read-only DRF API to reduce DB load on the heavier viewsets (Issue, Series, etc.), with automatic invalidation on writes — no manual cache-busting to maintain.

Two schemes, matched to endpoint shape:

  • Detail (retrieve, and detail-scoped actions like issue_list) cache under a key derived from the object's pk + modified timestamp. A write changes modified, which changes the key, so old entries are simply orphaned and expire via TTL (24h) — no explicit delete needed.
  • List (list, plus series_list) cache under a per-model cache-generation counter in Redis, bumped by the existing modified-cascade signals whenever that model (or Issue, for the Series list's embedded num_issues) changes.

Applied to the 9 public catalog viewsets (Arc, Character, Creator, Imprint, Issue, Publisher, Series, Team, Universe). Deliberately not applied to Collection/PullList/WishList/ReadingList — those are user-scoped (get_queryset filters by request.user), and a shared list-cache key would leak one user's data to another; that's left uncached for now rather than bolted on in this PR.

Also fixes a gap where Credits changes never bumped the parent Issue's modified timestamp (so a credit could change without invalidating the issue's cached/conditional-request state), and adds the missing cache-invalidation signal wiring for Publisher/Imprint/Universe, which previously had none.

DETAIL_CACHE_TTL/LIST_CACHE_TTL are both single constants in api/cache.py — starting at 24h/2min, to be tuned against production Redis memory usage.

Follow-up fixes

A review of the caching design surfaced a few gaps, fixed here:

  • Staleness: Arc/Character/Team issue_list and Series retrieve were cached under the parent object's own modified timestamp, which doesn't change on every edit that affects the cached payload (a plain issue field edit doesn't bump the parent Arc/Character/Team; a Publisher/Imprint rename doesn't bump the owning Series). Both now also key off the dependent model's cache-generation counter.
  • Race: CreditSerializer.create() bumped Issue.modified before attaching credit roles, so a request landing in that window could cache an issue with an empty role list under a key nothing would later invalidate. A new m2m_changed signal on Credits.role bumps again once roles actually land.
  • Query cost: the cheap (pk, modified) lookup used for conditional requests and cache-key computation was still carrying the retrieve queryset's annotate() aggregates into an extra JOIN + GROUP BY on every request (cache hits included) for Issue/Series. Fixed via a leaner get_modified_queryset().

Further fixes and cleanup

A second review pass caught a few more gaps in the same class as above, plus some efficiency/maintainability polish:

  • More staleness gaps: Issue retrieve embeds Series/Publisher/Imprint names, and Imprint/Universe retrieve embed their Publisher's name — none of which cascaded a modified bump onto the cached object. Arc/Character/Team issue_list also nests each issue's Series name without depending on it. All now mix in the relevant model's version counter, same pattern already used for Series. (Character/Team retrieve still omit Creator/Universe on purpose — those are edited/added far more often than Publisher/Imprint, so tracking them would tank the cache hit rate for comparatively little benefit; documented inline.)
  • PublisherViewSet.series_list existence check: skipped get_object() (and therefore the existence/permission check) entirely on a cache hit — a deleted publisher's cached series list kept returning 200 instead of 404 until the 2min list-cache TTL caught up.
  • Cleanup: ModelLabel is now a StrEnum (a typo'd label is caught by type checking instead of silently creating a dead, never-invalidated counter); list_cache_key()/detail_cache_key() batch dependent-label lookups into one Redis get_many() instead of one get() per label; get_model_version()'s cold-start path trimmed from 3 Redis round trips to 2; the 16 near-identical per-model post_save/post_delete connections in comicsdb/apps.py collapsed into a table-driven loop.
  • audit_response_cache management command: a one-off audit of the response cache's Redis footprint (key counts/estimated memory per category, hit rate, eviction stats), meant to be run against production after this deploys to inform whether DETAIL_CACHE_TTL/LIST_CACHE_TTL need tuning rather than guessing.

Detail responses cache under a self-versioning key derived from the object's `modified` timestamp, so writes invalidate automatically with no explicit cache-busting. List responses cache under a per-model version counter in Redis, bumped by the existing modified-cascade signals (extended here to cover Publisher/Imprint/Universe/Series/Arc/Character/Team/Issue, plus a new Credits -> Issue cascade that was previously missing). User-scoped viewsets (Collection/PullList/WishList/ReadingList) are intentionally excluded from list caching to avoid leaking one user's data to another.
@bpepple bpepple self-assigned this Aug 20, 2026
@bpepple bpepple added enhancement New feature or request api An API bug/feature labels Aug 20, 2026
Several detail/action caches keyed off an object's own `modified` missed edits to related data that don't cascade a bump onto it:
Arc/Character/Team issue_list didn't see plain issue field edits, and Series retrieve didn't see Publisher/Imprint renames. Both now mix the dependent model's version counter into the cache key.

Also fixes a race where CreditSerializer.create() bumped Issue.modified before attaching roles, letting a request cache an issue with an empty role list under a key nothing would ever invalidate; a new m2m_changed signal on Credits.role bumps again once roles actually land.

IssueViewSet/SeriesViewSet's cheap (pk, modified) lookup was still carrying the retrieve queryset's annotate() aggregates into an extra JOIN + GROUP BY on every request; get_modified_queryset() now skips them. Also drops the now-unnecessary deferred api.cache imports in signals.py and de-duplicates the per-model cache-bump functions.
…e check

Issue retrieve embeds Series/Publisher/Imprint names, and Imprint/Universe retrieve embed their Publisher's name, none of which cascade a `modified` bump onto the cached object. Arc/Character/Team issue_list also nests each issue's Series name without depending on it. All now mix in the relevant model's version counter, same pattern already used for Series. Character/
Team retrieve still omit Creator/Universe on purpose -- those are edited far more often than Publisher/Imprint, so tracking them would tank thecache hit rate for comparatively little benefit; documented inline.

Also fixes PublisherViewSet.series_list, which skipped get_object() (and therefore the existence/permission check) entirely on a cache hit -- a deleted publisher's cached series list kept returning 200 instead of 404 until the 2min list-cache TTL caught up.
ModelLabel is now a StrEnum instead of a plain string-constant class, so a typo'd label is caught by type checking instead of silently creating a version counter that's never invalidated.

list_cache_key()/detail_cache_key() now fetch all dependent labels' version counters in one cache.get_many() instead of one cache.get() per label, and get_model_version()'s cold-start path drops from 3 Redis round trips to 2 (1 for the winning first caller) by checking cache.add()'s return value instead of re-reading afterward.

comicsdb/apps.py's 16 near-identical post_save/post_delete .connect() calls for the 8 "just bump my version counter" models (Arc, Character, Creator, Imprint, Publisher, Series, Team, Universe) collapse into a table-driven loop backed by a single bump_cache() receiver in signals.py, replacing the 8 near-identical wrapper functions it used to require.
weak=False is required here since the loop's functools.partial receivers have no other strong reference; verified all 8 models still bump their counter on save and delete before trusting it.
Deciding whether to raise or lower DETAIL_CACHE_TTL/LIST_CACHE_TTL needs actual production data, not guesswork: how much of Redis the API cache accounts for, how it's split across models, and whether Redis is already evicting under memory pressure before the TTL ever gets a say.

Scans the full keyspace once (cursor-based, safe against a large production keyspace), buckets keys by api:detail:<model>/api:list:<model>/cachever/other, and estimates per-bucket memory by sampling MEMORY USAGE rather than calling it on every key. Also reports global hit rate and evicted_keys from Redis INFO. Meant to be run manually against production after this caching work deploys, and periodically afterward to compare.
@bpepple
bpepple merged commit 48708f6 into master Aug 20, 2026
2 checks passed
@bpepple
bpepple deleted the api-cache branch August 20, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api An API bug/feature enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant