# ADR-004: Caching Strategy

## Status

Accepted

## Context

ThrillWiki serves data that is:
- Read-heavy (browsing parks and rides)
- Moderately updated (user contributions, moderation)
- Geographically queried (map views, location searches)

We needed a caching strategy that would:
- Reduce database load for common queries
- Provide fast response times for users
- Handle cache invalidation correctly
- Support different caching needs (sessions, API, geographic)

## Decision

We implemented a **Multi-Layer Caching Strategy** using Redis with multiple cache backends for different purposes.

### Cache Architecture

```
┌─────────────────────────────────────────────────────┐
│                     Application                      │
└─────────────────────────────────────────────────────┘
              │                │                │
              ▼                ▼                ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│  Default Cache  │ │  Session Cache  │ │   API Cache     │
│  (General data) │ │  (User sessions)│ │  (API responses)│
└─────────────────┘ └─────────────────┘ └─────────────────┘
              │                │                │
              └────────────────┼────────────────┘
                               ▼
                    ┌─────────────────┐
                    │      Redis      │
                    │   (with pools)  │
                    └─────────────────┘
```

### Cache Configuration

```python
# backend/config/django/production.py
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": redis_url,
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
            "PARSER_CLASS": "redis.connection.HiredisParser",
            "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
            "CONNECTION_POOL_CLASS_KWARGS": {
                "max_connections": 100,
                "timeout": 20,
            },
        },
        "KEY_PREFIX": "thrillwiki",
    },
    "sessions": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": redis_sessions_url,
        "KEY_PREFIX": "sessions",
    },
    "api": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": redis_api_url,
        "OPTIONS": {
            "COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
        },
        "KEY_PREFIX": "api",
    },
}
```

### Caching Layers

| Layer | Purpose | TTL | Invalidation |
|-------|---------|-----|--------------|
| QuerySet | Expensive database queries | 1 hour | On model save |
| API Response | Serialized API responses | 30 min | On data change |
| Geographic | Map data and location queries | 30 min | On location update |
| Template Fragment | Rendered template parts | 15 min | On context change |

## Consequences

### Benefits

1. **Reduced Database Load**: Common queries served from cache
2. **Fast Response Times**: Sub-millisecond cache hits
3. **Scalability**: Cache can be distributed across Redis cluster
4. **Flexibility**: Different TTLs for different data types
5. **Compression**: Reduced memory usage with zlib compression

### Trade-offs

1. **Cache Invalidation**: Must carefully invalidate on data changes
2. **Memory Usage**: Redis memory must be monitored
3. **Consistency**: Potential for stale data during TTL window
4. **Complexity**: Multiple cache backends to manage

### Cache Key Naming Convention

```
{prefix}:{entity_type}:{identifier}:{context}

Examples:
thrillwiki:park:123:detail
thrillwiki:park:123:rides
api:parks:list:page1:filter_operating
geo:bounds:40.7:-74.0:41.0:-73.5:z10
```

### Cache Invalidation Patterns

```python
# Model signal for cache invalidation
@receiver(post_save, sender=Park)
def invalidate_park_cache(sender, instance, **kwargs):
    cache_service = EnhancedCacheService()

    # Invalidate specific park cache
    cache_service.invalidate_model_cache('park', instance.id)

    # Invalidate list caches
    cache_service.invalidate_pattern('api:parks:list:*')

    # Invalidate geographic caches if location changed
    if instance.location_changed:
        cache_service.invalidate_pattern('geo:*')
```

## Alternatives Considered

### Database-Only (No Caching)

**Rejected because:**
- High database load for read-heavy traffic
- Slower response times
- Database as bottleneck for scaling

### Memcached

**Rejected because:**
- Less feature-rich than Redis
- No data persistence
- No built-in data structures

### Application-Level Caching Only

**Rejected because:**
- Not shared across application instances
- Memory per-instance overhead
- Cache cold on restart

## Implementation Details

### EnhancedCacheService

```python
# backend/apps/core/services/enhanced_cache_service.py
class EnhancedCacheService:
    """Comprehensive caching service with multiple cache backends."""

    def cache_queryset(self, cache_key, queryset_func, timeout=3600, **kwargs):
        """Cache expensive querysets with logging."""
        cached = self.default_cache.get(cache_key)
        if cached is None:
            result = queryset_func(**kwargs)
            self.default_cache.set(cache_key, result, timeout)
            return result
        return cached

    def invalidate_pattern(self, pattern):
        """Invalidate cache keys matching pattern."""
        if hasattr(self.default_cache, 'delete_pattern'):
            return self.default_cache.delete_pattern(pattern)
```

### Cache Warming

```python
# Proactive cache warming for common queries
class CacheWarmer:
    """Context manager for batch cache warming."""

    def warm_popular_parks(self):
        parks = Park.objects.operating()[:100]
        for park in parks:
            self.cache_service.warm_cache(
                f'park:{park.id}:detail',
                lambda: ParkSerializer(park).data,
                timeout=3600
            )
```

### Cache Monitoring

```python
class CacheMonitor:
    """Monitor cache performance and statistics."""

    def get_cache_stats(self):
        redis_client = self.cache_service.default_cache._cache.get_client()
        info = redis_client.info()
        hits = info.get('keyspace_hits', 0)
        misses = info.get('keyspace_misses', 0)
        return {
            'used_memory': info.get('used_memory_human'),
            'hit_rate': hits / (hits + misses) * 100 if hits + misses > 0 else 0,
        }
```

## References

- [Django Redis](https://github.com/jazzband/django-redis)
- [Redis Documentation](https://redis.io/documentation)
- [Cache Invalidation Strategies](https://en.wikipedia.org/wiki/Cache_invalidation)
