# Cache subsystem
Active contributors: Saksham, Ravi
The cache subsystem provides a unified, backend-agnostic caching layer under `app/core/cache/`. It supports three pluggable backends (Redis, in-memory LRU, disk), a `CacheManager` facade that handles backend selection and graceful fallback, decorators for transparent function-level caching, consistent key generation, and a `PropertyCacheManager` specialized for property queries. The subsystem is wired into app startup and shutdown through `initialize_cache()` / `shutdown_cache()` in lifespan.
## Directory layout
```
app/core/cache/
├── __init__.py # Public API, global manager, PropertyCacheManager
├── interface.py # CacheBackend Protocol, CacheStats dataclass
├── manager.py # CacheManager facade, CacheBackendType, NullCacheBackend
├── decorators.py # @cached, @invalidate_cache
├── keys.py # build_cache_key, generate_hash, CacheKeyPatterns
└── backends/
├── __init__.py # Exports the three backends
├── redis.py # RedisCacheBackend (JSON + pickle fallback, SCAN invalidation)
├── memory.py # InMemoryCacheBackend (LRU OrderedDict, TTL)
└── disk.py # DiskCacheBackend (in-memory index + pickle files)
```
## Key abstractions
| Abstraction | Location | Purpose |
|---|---|---|
| `CacheBackend` Protocol | `app/core/cache/interface.py` | Structural typing for backends (`get`, `set`, `get_and_delete`, `delete`, `delete_pattern`, `exists`, `clear`, `connect`, `disconnect`, `is_available`) |
| `CacheManager` | `app/core/cache/manager.py` | Facade over primary + fallback backends, delegates to active backend |
| `NullCacheBackend` | `app/core/cache/manager.py` | No-op backend for graceful degradation when caching is disabled |
| `CacheBackendType` | `app/core/cache/manager.py` | Enum: `disk`, `memory`, `redis` |
| `@cached(prefix, ttl, ...)` | `app/core/cache/decorators.py` | Cache async function results by args/kwargs |
| `@invalidate_cache(patterns)` | `app/core/cache/decorators.py` | Delete matching keys after a function runs |
| `build_cache_key` / `generate_hash` | `app/core/cache/keys.py` | Consistent colon-joined keys with MD5 hash of kwargs |
| `CacheKeyPatterns` | `app/core/cache/keys.py` | Standard invalidation patterns (`properties:*`, `property:{id}:*`, etc.) |
| `PropertyCacheManager` | `app/core/cache/__init__.py` | Property-specific helpers (search keys, detail keys, invalidation) |
| `get_cache_manager` / `initialize_cache` / `shutdown_cache` | `app/core/cache/__init__.py` | Global singleton lifecycle |
## How it works
```mermaid
graph TD
Settings["settings.CACHE_BACKEND
+ SERVERLESS_ENABLED"] --> Factory["CacheManager.create_from_config"]
Factory -->|serverless| Memory["InMemoryCacheBackend
no Redis keep-alive"]
Factory -->|redis| Redis["RedisCacheBackend
+ in-memory fallback"]
Factory -->|disk| Disk["DiskCacheBackend
+ in-memory fallback"]
Factory -->|memory| MemOnly["InMemoryCacheBackend
no fallback"]
Redis --> Manager["CacheManager
primary + fallback"]
Disk --> Manager
Memory --> Manager
MemOnly --> Manager
Manager --> Active["backend property
primary if available, else fallback"]
Cached["@cached decorator"] --> Key["build_cache_key"]
Key --> ManagerGet["cache.get(key)"]
ManagerGet -->|hit| Return["return cached value"]
ManagerGet -->|miss| Func["run function"]
Func --> ManagerSet["cache.set(key, value, ttl)"]
Invalidate["@invalidate_cache"] --> ManagerDel["cache.delete_pattern"]
```
### Backend selection
`CacheManager.create_from_config(settings)` picks the backend. When `SERVERLESS_ENABLED` is true it forces `InMemoryCacheBackend` so no persistent Redis keep-alive packets prevent Railway scale-to-zero. Otherwise it reads `CACHE_BACKEND` (default `disk`): `redis` uses `RedisCacheBackend` with an `InMemoryCacheBackend` fallback, `disk` uses `DiskCacheBackend` with an in-memory fallback, and `memory` uses in-memory with no fallback. The fallback is wired so a Redis outage degrades to in-memory rather than failing requests.
The `CacheManager` exposes a `backend` property that returns the primary if it `is_available()`, otherwise the fallback. `connect()` tries the primary and flips `_use_fallback` if it fails; `disconnect()` closes both. All operations (`get`, `set`, `get_and_delete`, `delete`, `delete_pattern`, `exists`, `clear`) delegate to the active backend.
### Backends
- **`RedisCacheBackend`** — JSON serialization with pickle fallback for non-JSON-serializable values, connection pooling, pattern-based invalidation via `SCAN` (non-blocking), graceful degradation on connection issues. Uses a configurable `key_prefix` (default `ghar360:`).
- **`InMemoryCacheBackend`** — Thread-safe LRU `OrderedDict` with TTL, `asyncio.Lock` for safety, `fnmatch` for pattern deletion, and `CacheStats` tracking. Bounded by `max_size` and `max_entry_bytes`.
- **`DiskCacheBackend`** — In-memory metadata index (`OrderedDict` of `DiskCacheEntry`) plus pickle files on disk under `CACHE_DISK_DIR`. Hashed filenames, size accounting, TTL expiry.
### Decorators
`@cached(prefix, ttl=300, key_params=None, include_user=False, cache_none=False, condition=None)` wraps an async function. It builds a cache key from the prefix, optional user id, and either the specified `key_params` or all serializable kwargs (excluding `db`, `current_user`, `request`, `session`). On a hit it returns the cached value; on a miss it runs the function, optionally caches the result (skipping `None` unless `cache_none`, and respecting an optional `condition` callable), and serializes Pydantic models via `model_dump(mode="json")` before storage. Cache errors are logged as warnings and never propagate — a cache failure falls through to the function.
`@invalidate_cache(patterns)` runs the function first, then deletes all keys matching the given pattern(s) via `cache.delete_pattern`. Used on mutations to evict stale entries.
### Keys
`build_cache_key(prefix, *args, include_user=False, user_id=None, **kwargs)` joins colon-separated parts: prefix, positional args, optional `u{user_id}`, and an MD5 hash of the filtered kwargs. `generate_hash` serializes dicts with `sort_keys=True` for deterministic hashing. `CacheKeyPatterns` defines standard invalidation patterns and helpers like `for_property(id)` and `for_user(id)`.
### PropertyCacheManager
`PropertyCacheManager` (defined in `app/core/cache/__init__.py`) provides property-specific helpers: `generate_cache_key(filters, user_id, page, limit)` produces `properties:v1:{filter_hash}:u{user_id}:p{page}:l{limit}`, `detail_cache_key(id)` produces `property:{id}:v1`, and `invalidate_property_caches` / `invalidate_property_detail_cache` delete the relevant patterns. Services in `app/services/property/` use these to cache list and detail responses and invalidate on create/update/delete.
## Integration points
- **Lifespan** calls `initialize_cache()` at startup and `shutdown_cache()` at teardown. See [infrastructure](systems--infrastructure.md).
- **Property services** use `PropertyCacheManager` and the `@cached` decorator. See [services-layer](systems--services-layer.md).
- **OAuth token store** (`app/services/oauth_token_store.py`) uses `CacheManager.get_and_delete` for atomic token/code exchange. See [features/mcp-servers](features--mcp-servers.md).
- **Serverless mode** forces in-memory cache to allow scale-to-zero. See [core-cross-cutting](systems--core-cross-cutting.md).
## Entry points for modification
- New cached query: add `@cached("prefix", ttl=...)` to the service function, choosing `key_params` carefully to avoid cross-user leakage.
- New invalidation pattern: add it to `CacheKeyPatterns` and call `@invalidate_cache` on the mutating function.
- New backend: implement the `CacheBackend` protocol and add a branch in `CacheManager.create_from_config`.
## Key source files
| File | Role |
|---|---|
| `app/core/cache/__init__.py` | Public API, global manager, `PropertyCacheManager` |
| `app/core/cache/interface.py` | `CacheBackend` protocol, `CacheStats` |
| `app/core/cache/manager.py` | `CacheManager` facade, `NullCacheBackend`, backend factory |
| `app/core/cache/decorators.py` | `@cached`, `@invalidate_cache` |
| `app/core/cache/keys.py` | Key generation and patterns |
| `app/core/cache/backends/redis.py` | Redis backend |
| `app/core/cache/backends/memory.py` | In-memory LRU backend |
| `app/core/cache/backends/disk.py` | Disk-backed backend |