# Infrastructure Cache Pattern (Clean Architecture)

Adding a cache layer behind a Clean Architecture port, with in-memory and Redis backends.

## Overview

Cache is an infrastructure concern. The domain layer must not know about it. The use case (application layer) can optionally depend on a cache port — and the decision to cache or not is a wiring detail, not a business rule.

## Port Design

```python
# application/interfaces.py
from typing import Protocol

class StreamResolveCache(Protocol):
    async def get(self, key: str) -> list[StreamResolveResult] | None: ...
    async def set(self, key: str, results: list[StreamResolveResult], ttl_seconds: int) -> None: ...
    async def clear(self) -> None: ...
```

**Design decisions:**
- `None` means cache miss (not "empty result")
- Cache is optional in the use case constructor (`cache: StreamResolveCache | None = None`)
- Fail-open: cache errors never break resolution; exceptions caught internally become miss/no-op

## Cache Key Policy

Cache keys must be deterministic and scoped to avoid cross-user/cross-account leakage.

```python
def compute_cache_key(user_id: int, account_id: int, provider: str, source: ResolveSource) -> str:
    raw = f"{user_id}|{account_id}|{provider}|{source.source_type.value}|{_canonicalize_source(source)}"
    digest = hashlib.sha256(raw.encode()).hexdigest()
    return f"resolve-cache:v1:{digest}"
```

**Key components:** user_id, account_id, provider, source_type, canonical source identity.

**Canonicalization rules:**
- Magnet links → extract BTIH info-hash (same torrent with different trackers = same key)
- Info-hash → lowercase (case-insensitive)
- Direct/torrent URLs → SHA-256 of the full URL (treats query params as identity)
- Raw magnet without BTIH → SHA-256 fallback (should not normally happen)

## In-Memory Adapter

For local development and deterministic tests:

```python
class MemoryStreamResolveCache:
    def __init__(self):
        self._store: dict[str, _CacheEntry] = OrderedDict()

    async def get(self, key: str) -> list[StreamResolveResult] | None:
        entry = self._store.get(key)
        if entry is None:
            return None
        if entry.expired():
            del self._store[key]
            return None
        return copy.deepcopy(entry.results)  # mutation safety

    async def set(self, key, results, ttl_seconds):
        if ttl_seconds < 0:
            return
        self._store[key] = _CacheEntry(
            results=copy.deepcopy(results),
            expires_at=time.monotonic() + ttl_seconds,
        )
```

**Key design choices:**
- `copy.deepcopy` on get/set to prevent caller-side mutation of cached data
- Lazy expiry: entries are only evicted on access, no background cleanup needed
- Negative TTL = no-op (don't store)

## Redis Adapter

For production multi-process deployments:

```python
class _RedisClient(Protocol):
    """Minimal async Redis client interface — duck-type against redis.asyncio.Redis."""
    async def get(self, key: str) -> str | None: ...
    async def setex(self, key: str, seconds: int, value: str) -> None: ...
    async def delete(self, *keys: str) -> None: ...
    async def scan(self, cursor: int = 0, match: str = "*", count: int = 100) -> tuple[int, list[str]]: ...
```

**Serialization strategy:**
- Convert frozen dataclass to JSON-safe dict → `json.dumps`
- On read: `json.loads` → validate each field exists and is the right type → reconstruct
- Malformed/corrupt payloads = cache miss (not crash)
- Use `str(mime) if mime else None` pattern for nullable fields

**Fail-open:** All Redis connection errors caught at the adapter boundary → `get` returns None, `set` silently fails.

```python
async def get(self, key: str) -> list[StreamResolveResult] | None:
    try:
        raw = await self._redis.get(self._prefixed(key))
    except Exception:
        return None
    # ... deserialize, validate ...
```

## Use Case Integration

```python
class StreamResolveUseCase:
    def __init__(self, account_lookup, resolvers, cache: StreamResolveCache | None = None):
        self._cache = cache

    async def resolve(self, user_id, request):
        # ... account selection ...

        # Cache check (fail-open)
        if self._cache is not None:
            cache_key = compute_cache_key(user_id, account_id, provider, source)
            cached = await self._try_cache_get(cache_key)
            if cached is not None:
                return cached

        # ... resolve via provider ...

        # Cache store (fail-open, only non-empty results)
        if self._cache is not None and results:
            await self._try_cache_set(cache_key, results, provider)

        return results

    async def _try_cache_get(self, key):
        try:
            return await self._cache.get(key)
        except Exception:
            return None

    async def _try_cache_set(self, key, results, provider_name):
        try:
            ttl = bounded_cache_ttl_seconds(default_cache_ttl_seconds())
            await self._cache.set(key, results, ttl)
        except Exception:
            pass
```

**Rules:**
- Do NOT cache provider failures or errors
- Do NOT cache empty results (could be transient)
- Cache only after successful resolution
- Cache TTL: bounded, conservative (default 15 min, max 1 hour)

## Dependency Wiring

```python
# presentation/dependencies.py
STREAM_RESOLVE_CACHE: StreamResolveCache = MemoryStreamResolveCache()

def get_stream_resolve_use_case(
    request: Request,
    account_lookup: Annotated[...],
) -> StreamResolveUseCase:
    return StreamResolveUseCase(
        account_lookup=account_lookup,
        resolvers=stream_resolvers,
        cache=STREAM_RESOLVE_CACHE,
    )
```

For production, swap `MemoryStreamResolveCache` for `RedisStreamResolveCache(redis_client)`.

## Testing the Cache Integration

Use a `MockCache` class that implements the port Protocol:

```python
class MockCache(StreamResolveCache):
    def __init__(self):
        self._store = {}
        self.get_called = []
        self.set_called = []
        self._fail_get = False
        self._fail_set = False

    def fail_get_on_next(self):
        self._fail_get = True

    async def get(self, key):
        self.get_called.append(key)
        if self._fail_get:
            self._fail_get = False
            raise RuntimeError("cache get failure")
        return self._store.get(key)

    # ... set, clear ...
```

**Key test scenarios:**
1. Cache miss → resolver called → results stored in cache
2. Cache hit → results returned without calling resolver
3. Cache get failure → fail-open (resolver still called, results returned)
4. Cache set failure → fail-open (results returned despite cache error)
5. No cache configured → use case works exactly as before
6. Different account/user → different cache keys (no cross-contamination)
