Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions api/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Redis-backed response caching for the read-only DRF API.

Two independent invalidation schemes are used, depending on endpoint shape:

* Detail responses (retrieve, and detail-scoped actions like issue_list) are
cached under a self-versioning key derived from the object's ``modified``
timestamp -- when ``modified`` changes, the key changes with it, so old
entries are simply orphaned and expire via TTL. No explicit invalidation
is needed.
* List responses (list, and collection-scoped actions like series_list) are
cached under a key that includes a per-model cache-generation counter in
Redis, bumped by signal handlers (see comicsdb/signals.py) whenever data a
list response could embed changes.
"""

import hashlib
from collections.abc import Iterable
from enum import StrEnum
from typing import Any

from django.core.cache import cache

DETAIL_CACHE_TTL = 60 * 60 * 24 # 24h safety net; live keys self-invalidate on write.
LIST_CACHE_TTL = 60 * 2 # 2min; bounds staleness from nested-object changes we don't chase.

_VERSION_KEY_PREFIX = "cachever"


class ModelLabel(StrEnum):
"""Stable cache-key labels shared between signal handlers and views."""

ARC = "arc"
CHARACTER = "character"
CREATOR = "creator"
IMPRINT = "imprint"
ISSUE = "issue"
PUBLISHER = "publisher"
SERIES = "series"
TEAM = "team"
UNIVERSE = "universe"


def detail_cache_key(model_label: str, pk: Any, modified, *dependent_labels: str) -> str:
"""Cache key for a single object's serialized detail response.

Self-invalidating: a change to `modified` produces a new key, so old
entries are simply orphaned and expire via TTL.

`dependent_labels` (optional) mix in other models' version counters, for
responses that embed data from a related object whose own edits don't
cascade a `modified` bump onto this one (e.g. a Series response embeds
its Publisher's name, but renaming the Publisher doesn't touch the
Series row).
"""
key = f"api:detail:{model_label}:{pk}:{modified.timestamp()}"
if dependent_labels:
version_map = get_model_versions(dependent_labels)
versions = "-".join(str(version_map[lbl]) for lbl in dependent_labels)
key = f"{key}:{versions}"
return key


def get_model_version(model_label: str) -> int:
"""Return the current cache-generation counter for a model, initializing
it to 1 on first use."""
key = f"{_VERSION_KEY_PREFIX}:{model_label}"
version = cache.get(key)
if version is not None:
return version
if cache.add(key, 1, timeout=None):
return 1
# Lost the initialization race to another caller -- read back what they set.
return cache.get(key) or 1


def get_model_versions(model_labels: Iterable[str]) -> dict[str, int]:
"""Batch form of get_model_version(): one Redis round trip (get_many)
for the common case where every counter already exists, instead of one
round trip per label."""
labels = list(dict.fromkeys(model_labels)) # de-dupe, preserve order
keys = {lbl: f"{_VERSION_KEY_PREFIX}:{lbl}" for lbl in labels}
cached = cache.get_many(keys.values())
return {
lbl: cached[key] if key in cached else get_model_version(lbl) for lbl, key in keys.items()
}


def bump_model_version(model_label: str) -> None:
"""Invalidate list caches that depend on `model_label` by advancing its
generation counter."""
key = f"{_VERSION_KEY_PREFIX}:{model_label}"
try:
cache.incr(key)
except ValueError:
# Key doesn't exist yet. At most one concurrent caller's `add` wins;
# the other's bump is harmlessly absorbed, since a version key that
# didn't exist means no list cache entry was ever computed under any
# version of it either.
cache.add(key, 1, timeout=None)


def list_cache_key(
model_label: str,
*dependent_labels: str,
query: Iterable[tuple[str, list[str]]],
scope: str = "",
) -> str:
"""Cache key for a list-type response: one or more model versions plus a
normalized hash of the request's query params.

`query` should come from `request.query_params.lists()` (multi-value),
not `.dict()` -- `.dict()` silently drops all-but-the-last value for
repeated params (e.g. IssueFilter's `role_id`), which would let distinct
multi-value requests collide on the same key.
"""
labels = (model_label, *dependent_labels)
version_map = get_model_versions(labels)
versions = "-".join(str(version_map[lbl]) for lbl in labels)
normalized = "&".join(f"{k}={v}" for k, v in sorted(query))
digest = hashlib.sha256(normalized.encode()).hexdigest()[:16]
return f"api:list:{model_label}:{scope}:{versions}:{digest}"
166 changes: 166 additions & 0 deletions api/management/commands/audit_response_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import random
import time
from collections import defaultdict

from django.core.cache import cache
from django.core.management.base import BaseCommand

_DETAIL_PREFIX = "api:detail:"
_LIST_PREFIX = "api:list:"
_VERSION_PREFIX = "cachever:"
_BYTES_PER_UNIT = 1024


class Command(BaseCommand):
help = (
"One-off audit of the Redis-backed API response cache -- key counts and "
"estimated memory footprint per category (api:detail:<model>, "
"api:list:<model>, cachever, everything else), plus global hit-rate and "
"eviction stats. Meant to be run against production shortly after the "
"caching PR deploys (and again later) to see whether DETAIL_CACHE_TTL/"
"LIST_CACHE_TTL (api/cache.py) need tuning, rather than guessing."
)

def add_arguments(self, parser) -> None:
parser.add_argument(
"--sample-size",
type=int,
default=300,
help=(
"Max keys to sample per category for MEMORY USAGE, so the "
"estimate doesn't require calling it on every key in a large "
"keyspace (default: 300)"
),
)
parser.add_argument(
"--scan-count",
type=int,
default=1000,
help="COUNT hint passed to Redis SCAN per iteration (default: 1000)",
)

def handle(self, *args, **options) -> None:
# Django's generic cache API has no SCAN/MEMORY USAGE/INFO -- those
# require the raw redis-py client the RedisCache backend wraps.
client = cache._cache.get_client()

self._print_global_stats(client)
categories, total_keys, elapsed = self._scan_and_categorize(client, options["scan_count"])
self.stdout.write(f"\nScanned {total_keys:,} keys in {elapsed:.1f}s.")
self._print_category_report(client, categories, options["sample_size"])

def _print_global_stats(self, client) -> None:
try:
memory = client.info("memory")
stats = client.info("stats")
except Exception as exc: # noqa: BLE001 -- best-effort diagnostics
self.stdout.write(
self.style.WARNING(f"INFO command unavailable ({exc}); skipping global stats.")
)
return

hits = stats.get("keyspace_hits", 0)
misses = stats.get("keyspace_misses", 0)
total = hits + misses
hit_rate = f"{hits / total:.1%}" if total else "n/a"

self.stdout.write(
self.style.MIGRATE_HEADING("Redis instance (global -- all keys, not just ours)")
)
self.stdout.write(f" used_memory: {memory.get('used_memory_human', '?')}")
maxmemory = memory.get("maxmemory", 0)
unbounded = " (unbounded -- no eviction policy in effect)" if not maxmemory else ""
self.stdout.write(f" maxmemory: {memory.get('maxmemory_human', '?')}{unbounded}")
self.stdout.write(f" maxmemory_policy: {memory.get('maxmemory_policy', '?')}")
self.stdout.write(f" mem_fragmentation: {memory.get('mem_fragmentation_ratio', '?')}")
self.stdout.write(f" keyspace hit rate: {hit_rate} ({hits:,} hits / {misses:,} misses)")

evicted = stats.get("evicted_keys", 0)
evicted_line = f" evicted_keys: {evicted:,}"
if evicted:
evicted_line = self.style.WARNING(
f"{evicted_line} <-- Redis is evicting under memory pressure; "
"TTLs alone aren't controlling memory here, lower them or add memory"
)
self.stdout.write(evicted_line)

def _scan_and_categorize(self, client, scan_count: int):
categories: dict[str, list[bytes]] = defaultdict(list)
total_keys = 0
cursor = 0
start = time.monotonic()
while True:
cursor, keys = client.scan(cursor=cursor, count=scan_count)
for raw_key in keys:
total_keys += 1
key = raw_key.decode() if isinstance(raw_key, bytes) else raw_key
categories[self._categorize(key)].append(raw_key)
if cursor == 0:
break
return categories, total_keys, time.monotonic() - start

@staticmethod
def _categorize(key: str) -> str:
# Substring match rather than startswith(): robust to however Django's
# RedisCache backend wraps the logical key (e.g. a ":<version>:"
# prefix), without needing to know its exact format.
if _DETAIL_PREFIX in key:
model = key.split(_DETAIL_PREFIX, 1)[1].split(":", 1)[0]
return f"api:detail:{model}"
if _LIST_PREFIX in key:
model = key.split(_LIST_PREFIX, 1)[1].split(":", 1)[0]
return f"api:list:{model}"
if _VERSION_PREFIX in key:
return "cachever"
return "other (Select2, throttling, etc.)"

def _print_category_report(self, client, categories: dict, sample_size: int) -> None:
self.stdout.write(self.style.MIGRATE_HEADING("\nBy category"))
if not categories:
self.stdout.write(" No keys found.")
return

rows = []
for label, keys in categories.items():
count = len(keys)
avg_bytes = self._sample_avg_memory(client, keys, sample_size)
est_total = avg_bytes * count if avg_bytes is not None else None
rows.append((label, count, avg_bytes, est_total))
rows.sort(key=lambda row: row[3] or 0, reverse=True)

for label, count, avg_bytes, est_total in rows:
avg_str = f"~{avg_bytes:,.0f} B/key" if avg_bytes is not None else "n/a"
total_str = self._human_bytes(est_total)
self.stdout.write(f" {label:<32} {count:>8,} keys {avg_str:>14} est. {total_str}")

known = sum(est for *_ignored, est in rows if est is not None)
self.stdout.write(
f"\n Estimated total across sampled categories: {self._human_bytes(known)}"
)
self.stdout.write(
" (Estimates extrapolate from a random sample's MEMORY USAGE -- "
"re-run periodically and compare, not just once.)"
)

@staticmethod
def _sample_avg_memory(client, keys: list, sample_size: int) -> float | None:
sample = keys if len(keys) <= sample_size else random.sample(keys, sample_size)
sizes = []
for key in sample:
try:
size = client.memory_usage(key)
except Exception: # noqa: BLE001, S112 -- best-effort; key may have expired mid-scan
continue
if size is not None:
sizes.append(size)
return sum(sizes) / len(sizes) if sizes else None

@staticmethod
def _human_bytes(n: float | None) -> str:
if n is None:
return "n/a"
for unit in ("B", "KB", "MB", "GB"):
if n < _BYTES_PER_UNIT:
return f"{n:,.1f} {unit}"
n /= _BYTES_PER_UNIT
return f"{n:,.1f} TB"
Loading
Loading