Cache reads with Redis
Reading the same document over and over shouldn't hit the database every time. Attach a cache to its specification and wire a Redis backend: reads serve from Redis, writes invalidate. The handlers don't change — caching is pure wiring.
The runnable version of this recipe lives at examples/recipes/cache_reads/ —
just run brings up ephemeral Postgres + Redis, runs it, and tears it down.
Cache the aggregate¶
The Product is an ordinary document. The only caching-related line is the
cache= on its specification:
class Product(Document):
name: str
price: int
class ProductCreate(CreateDocumentCmd):
name: str
price: int
class ProductUpdate(BaseDTO):
name: str | None = None
price: int | None = None
class ProductRead(ReadDocument):
name: str
price: int
product_spec = DocumentSpec(
name="products",
read=ProductRead,
write=DocumentWriteTypes(
domain=Product, create_cmd=ProductCreate, update_cmd=ProductUpdate
),
cache=CacheSpec(name="products"), # reads cached, writes invalidate
)
CacheSpec(name="products") is the whole opt-in; its TTLs default sensibly.
Wire Postgres + Redis¶
Reads cache only once a cache backend is registered for the spec's
CacheSpec.name. Register the Redis cache next to the Postgres document module —
the "products" key is the same logical name on both sides:
def build_runtime(
pg: PostgresClient,
redis: RedisClient,
*,
pg_dsn: str,
redis_dsn: str,
) -> ExecutionRuntime:
deps = DepsRegistry.from_modules(
PostgresDepsModule(
client=pg,
rw_documents={"products": PRODUCT_PG},
tx={"products"},
),
# caches keyed by CacheSpec.name — this is the whole "cache reads" step
RedisDepsModule(
client=redis,
caches={"products": RedisCacheConfig(namespace="app:products")},
),
)
lifecycle = LifecyclePlan.from_modules(
PostgresLifecycleModule(client=pg, dsn=pg_dsn),
).with_steps(redis_lifecycle_step(dsn=redis_dsn))
return ExecutionRuntime(deps=deps.freeze(), lifecycle=lifecycle.freeze())
Postgres stores the documents; Redis answers the repeat reads.
What happens on read and write¶
The scenario builds the same typed DocumentFacade used by the CRUD examples;
only the dependency wiring adds caching:
registry = build_document_registry(product_spec).freeze()
def products(
ctx: ExecutionContext,
) -> DocumentFacade[ProductRead, ProductCreate, ProductUpdate]:
return DocumentFacade(
ctx=ctx,
registry=registry,
namespace=product_spec.default_namespace,
)
async def cache_scenario(ctx: ExecutionContext) -> ProductRead:
facade = products(ctx)
cache = ctx.cache(product_spec.cache) # pyright: ignore[reportArgumentType]
product = await facade.create(ProductCreate(name="Widget", price=10))
await facade.get(DocumentIdDTO(id=product.id)) # miss → fills the cache
if await cache.get(str(product.id)) is None: # cached now
raise RuntimeError("expected the read to fill the cache")
await facade.update(
DocumentUpdateDTO(
id=product.id,
rev=product.rev,
dto=ProductUpdate(price=12),
)
)
fresh = await facade.get(DocumentIdDTO(id=product.id)) # repopulates, new value
if fresh.price != 12:
raise RuntimeError("expected the cached read to see the new price")
return fresh
- The first
getmisses, loads from Postgres, and populates Redis. - Repeat
gets are served from Redis. - An
updateinvalidates the entry; the nextgetrepopulates it with the new value — so a cached read is never stale.
Going further¶
Read-through is the floor. The cache contract layers stampede protection with
background early refresh (serve the still-valid entry, refresh off the request
path), an in-process L1 (with W-TinyLFU admission and Redis push invalidation),
and adaptive TTLs on top — each a one-line addition to the CacheSpec, covered
in Caching reads.
Run it¶
cd examples/recipes/cache_reads
just run