Use Case 16: Inside the Cached Repository โ What's Actually Stored & Returned
Scenario: You want the exact contract of CachedBaseRepository โ what goes into the cache, in what shape, and why a cached read occasionally looks "wrong" for populated models โ before wiring it into a service.
๐ฆ The Cache Contract
| Aspect | Value |
|---|---|
| Key | "{key_prefix}{id}" โ default prefix "{collection_name}:" |
| Value | json.dumps(model_dump(by_alias=True), default=str) โ a byte string of JSON |
get_by_id hit |
decode_value(cached) โ self.model(**data) โ no DB hit |
get_by_id miss |
DB read, then the raw doc is cached (model_dump(by_alias=True)) |
create |
creates in DB, then caches the result (model_dump) |
update |
DB update, then set new value or delete the key if the doc vanished |
delete |
DB delete, then removes the key |
| TTL | config.default_ttl (default 300 s); in-memory eviction is LRU + expiry |
So the cache stores JSON snapshots of whole documents โ it is a read-through cache keyed by document id, not a query cache.
โ ๏ธ Intricacy #1 โ ObjectIds Become Hex Strings
json.dumps(..., default=str) stringifies every non-JSON value โ most importantly an ObjectId in a FK field:
# model in memory: User(id=..., profile=ObjectId("507f1f77bcf86cd799439011"))
# cached bytes: b'{"_id":"507f1f77bcf86cd799439011","profile":"507f1f77bcf86cd799439011", ...}'
On a cache hit, self.model(**data) must therefore accept a hex string where the raw doc held an ObjectId. This is fine for:
- id (typed PyObjectId, which accepts both str and ObjectId), and
- created_at / updated_at (ISO strings coerce to datetime).
It is not fine for a field typed as a model.
โ ๏ธ Intricacy #2 โ Model-Typed FK Fields Fail on a Cache Hit
If your document has a populated field, e.g.:
then the base-class hit path self.model(**data) receives profile="507fโฆ" and Pydantic raises a ValidationError โ a string cannot coerce into a Profile. The miss path fails the same way: BaseRepository.get_by_id materializes the model from the raw doc whose profile is an ObjectId โ also a ValidationError. So a plain CachedBaseRepository cannot materialize a model-typed FK field at all, hit or miss โ the problem is the base classes build a typed model straight from raw docs.
The tension is structural: PopulatingRepository types the field as the model; CachedBaseRepository stores the raw (depopulated) shape. You cannot have a single typed model serve both at once. The two resolutions:
- Compose โ keep
profile: Profile | Noneand cache raw, populating on read. Exact recipe in use case 10: its miss path fetches the raw doc (bypassing the base model-build) and both paths normalizestr โ ObjectIdbefore populate, because the JSON round-trip hands you strings. - Type it as an id โ
profile: PyObjectId | Noneand no populate rules; then cached hits validate cleanly, but you've given up population entirely.
Do not cache a populated model object through the base class: create caches result.model_dump, so the first cache write stores the embedded-dict shape while later get_by_id misses would repopulate โ inconsistent shapes for the same key, and update overwrites with yet another. Pick one canonical raw shape and stick to it.
๐ Example โ Inspecting What Gets Stored
from mongo_ops import BaseDocument, CachedBaseRepository, ModelRegistry, MongoConnectionManager
from mongo_ops.cache import CacheConfig, InMemoryCacheBackend
class Product(BaseDocument):
name: str = ""
price: float = 0.0
cache = InMemoryCacheBackend(max_entries=10_000, default_ttl=600)
ModelRegistry.set_cache_backend(cache)
class ProductRepo(CachedBaseRepository[Product]):
def __init__(self):
super().__init__("products", Product, cache, CacheConfig(enabled=True, backend="memory"))
async def inspect_cache(repo_id: str) -> None:
# After create()/get_by_id(), inspect what is actually stored:
cached = await cache.get(f"products:{repo_id}")
# b'{"_id":"507f...","name":"Widget","price":9.99,"created_at":"2026-...","updated_at":"2026-..."}'
print(cached)
stats = await cache.get_stats()
print(stats.hits, stats.misses, stats.sets, stats.deletes) # CacheStats dataclass
โ ๏ธ Intricacy #3 โ Lifecycle & Sharing
- One shared backend instance. The repository needs it (
cache_backend=cache) and the registry needs it (ModelRegistry.set_cache_backend(cache)) soinitialize_cache()/shutdown_cache()manage the same object. Shutdown cancels the in-memory TTL cleanup task โ forgetting it leaks anasyncio.Taskat app exit. initialize_cache()starts the backend;initialize_all()creates indexes. Both come afterconnect().warm_cache([ids])skips keys that already exist, fetches the rest from the DB, and returns how many it wrote โ safe to call repeatedly.invalidate_cache(id)deletes one key;clear_pattern("products:*")wipes a collection.
๐ Redis Differences
| Behaviour | In-memory | Redis (redis.asyncio) |
|---|---|---|
| Key prefixing | prefix baked into the stored key | _full_key() applied on every op |
| TTL | heap-based, lazy eviction + cleanup task | Native SETEX |
| Invalidation broadcast | n/a | PUBLISH on mongo_ops:cache:invalidate on delete |
clear_pattern |
prefix match on stored keys | SCAN MATCH in batches |
๐ฏ Choosing the Right Layer
| Need | Use |
|---|---|
| Scalar docs, no refs โ cache-first reads | CachedBaseRepository (UC 07, UC 16) |
| Refs resolved on read, no caching | PopulatingRepository (UC 08, UC 15) |
| Refs and cache-first reads | composed subclass (UC 10) |
| Bulk warm on startup / cache-then-database failover | warm_cache + CacheStats |