6.1 KiB
UNDF: UNDF-2026-000000047
UNDF: (pending)
django-MOADX-0001: cached.Loader.get_template — stampede O(N) concurrent template compilations
MOAD-0006 Candidate — The Thundering Herd
| Field | Value |
|---|---|
| ID | django-MOADX-0001 |
| Severity | MEDIUM |
| Ecosystem | Django |
| Language | Python |
| File | django/template/loaders/cached.py |
| Lines | 28–65 |
| Pattern | dict.get → None check → expensive compile → dict[key]=template (no lock) |
| Trigger | N concurrent WSGI threads all request the same template during startup / cache clear |
| Compute cost | File system read + template lexing + compilation to AST nodes |
Defect
Django's cached.Loader wraps other template loaders and caches compiled Template objects in self.get_template_cache — a plain Python dict. The Loader instance is created once per engine alias (EngineHandler.__getitem__) and shared across all WSGI worker threads in the process.
# django/template/loaders/cached.py:28-65
def get_template(self, template_name, skip=None):
key = self.cache_key(template_name, skip)
cached = self.get_template_cache.get(key) # <-- dict lookup, no lock
if cached:
# ... return or raise from cache
return cached
# NO LOCK AROUND THIS BLOCK:
try:
template = super().get_template(template_name, skip) # <-- file I/O + compile
except TemplateDoesNotExist as e:
self.get_template_cache[key] = (
copy_exception(e) if self.engine.debug else TemplateDoesNotExist
)
raise
else:
self.get_template_cache[key] = template # <-- dict write
return template
super().get_template() walks each configured loader, opens the template file from disk, and compiles the template string through Django's lexer and parser into a Template object tree. This is orders of magnitude more expensive than a cached dict lookup.
CPython GIL consideration: Individual dict.get and dict[key] = value operations are atomic under CPython's GIL. However, the three-step sequence (check → file load + compile → write) is not atomic. Threads can and do interleave between the miss check and the subsequent write:
- Thread A:
cache.get(key)→None - Thread B:
cache.get(key)→None(before A has written) - Thread A:
super().get_template(...)→ opens file, parses template - Thread B:
super().get_template(...)→ opens file, parses template (duplicate work) - Thread A:
cache[key] = template - Thread B:
cache[key] = template(redundant write)
Grower signal: Harmless at low concurrency. Triggers on:
- Application startup: all templates cold-cached, first request burst loads all at once.
loader.reset()called during configuration reload: cache cleared, all threads stampede on first access.- Templates with
skipvariants generate distinct cache keys, multiplying stampede surface.
For a Django app with 50 templates and 32 WSGI workers, a loader.reset() followed by a traffic burst can produce 1600 redundant file reads and parses (32 workers × 50 templates).
Fix
Option A — threading.Lock per template key (fine-grained):
import threading
class Loader(BaseLoader):
def __init__(self, engine, loaders):
self.get_template_cache = {}
self._cache_locks = {}
self._meta_lock = threading.Lock()
self.loaders = engine.get_template_loaders(loaders)
super().__init__(engine)
def _get_or_create_key_lock(self, key):
with self._meta_lock:
if key not in self._cache_locks:
self._cache_locks[key] = threading.Lock()
return self._cache_locks[key]
def get_template(self, template_name, skip=None):
key = self.cache_key(template_name, skip)
cached = self.get_template_cache.get(key)
if cached:
if isinstance(cached, type) and issubclass(cached, TemplateDoesNotExist):
raise cached(template_name)
elif isinstance(cached, TemplateDoesNotExist):
raise copy_exception(cached)
return cached
key_lock = self._get_or_create_key_lock(key)
with key_lock:
# Double-check after acquiring lock
cached = self.get_template_cache.get(key)
if cached:
if isinstance(cached, type) and issubclass(cached, TemplateDoesNotExist):
raise cached(template_name)
elif isinstance(cached, TemplateDoesNotExist):
raise copy_exception(cached)
return cached
try:
template = super().get_template(template_name, skip)
except TemplateDoesNotExist as e:
self.get_template_cache[key] = (
copy_exception(e) if self.engine.debug else TemplateDoesNotExist
)
raise
else:
self.get_template_cache[key] = template
return template
Option B — coarse threading.RLock (simpler, acceptable given templates are rarely loaded):
self._lock = threading.RLock()
def get_template(self, template_name, skip=None):
key = self.cache_key(template_name, skip)
# Fast path without lock
cached = self.get_template_cache.get(key)
if cached:
...
with self._lock:
cached = self.get_template_cache.get(key)
if cached:
...
# compile under lock — acceptable since templates load once per key
...
Option C — functools.cached_property pattern or threading.local per-thread cache: Less correct since it defeats the purpose of a shared cache.
Option A is preferred: per-key locking means different templates compile concurrently, only the same template key serializes.
Impact
- Redundant work: N threads × file read + template parse per cache miss burst.
- Resource pressure: Multiple simultaneous file handles to the same template file; parser AST allocations multiplied by N.
- Fix: After fix, first miss for each key compiles once; subsequent requests wait and receive the cached result. Net: 1 compile per unique (template_name, skip) key regardless of concurrency.