Skip to content

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:

Python
# 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.:

Python
class User(BaseDocument):
    profile: Profile | None = None   # populate-ruled

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:

  1. Compose โ€” keep profile: Profile | None and 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 normalize str โ†’ ObjectId before populate, because the JSON round-trip hands you strings.
  2. Type it as an id โ€” profile: PyObjectId | None and 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

Python
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)) so initialize_cache() / shutdown_cache() manage the same object. Shutdown cancels the in-memory TTL cleanup task โ€” forgetting it leaks an asyncio.Task at app exit.
  • initialize_cache() starts the backend; initialize_all() creates indexes. Both come after connect().
  • 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