Caches
The Cache port provides a key-value interface for storing and retrieving projection data. Caches sit on the read side of CQRS, offering fast access to denormalized views built by projectors.
Overview
Protean's cache adapters store
Projection instances keyed by
their identifier. The BaseCache interface provides:
- Add/Get/Remove: Store and retrieve projections by key
- Pattern matching: Retrieve or remove projections by key pattern
- TTL management: Set time-to-live on cached entries
- Health checks: Verify cache connectivity
- Bulk operations: Flush all entries
Available Caches
Memory
The memory cache is the default. It stores projections in Python dictionaries
within the process. No external dependencies are needed.
- Use cases: Development, testing, prototyping
- All data is lost on process restart
- TTL is enforced lazily: every entry is written with an expiry, and an expired entry is dropped the next time it is read, iterated or counted. There is no background sweep, so an expired entry nobody touches still occupies memory.
Redis
The Redis cache provides durable, distributed caching with TTL support.
- Use cases: Production environments, multi-process deployments
- Requires: Redis server and the
redisPython package - Full TTL support with millisecond precision
Configuration
Caches are configured in the [caches] section of your domain configuration:
# Default: in-memory cache
[caches.default]
provider = "memory"
# Production: Redis cache
[caches.default]
provider = "redis"
URI = "redis://localhost:6379/2"
TTL = 300 # Default TTL in seconds
Configuration Options
| Option | Default | Description |
|---|---|---|
provider |
"memory" |
Cache provider (memory or redis) |
URI |
— | Redis connection URI (required for Redis) |
TTL |
300 |
Default time-to-live in seconds. See below. |
What counts as a TTL
A TTL must be a positive, finite number of seconds. It may also be a string holding one, so an environment variable works:
[caches.default]
provider = "redis"
TTL = "${CACHE_TTL|3600}"
Substitution runs over already-parsed TOML strings, so a TTL sourced from the environment arrives as a string; that is why the string form is accepted rather than merely tolerated.
Anything else, including 0, a negative, nan or inf, raises a
ConfigurationError naming the cache.
The same rule applies wherever a TTL is passed: cache.add(projection, ttl=...)
and cache.set_ttl(key, ttl) take the same shapes and reject the same ones.
Omitting the TTL (or passing an empty string) uses the cache's configured TTL.
What get_ttl returns when there is nothing to count
get_ttl(key) answers the seconds remaining before a key expires. Two states
are not durations, and every adapter answers them the same way:
| State | Memory cache | Redis cache |
|---|---|---|
| Key exists, expiry pending | seconds remaining | seconds remaining |
| No such key | returns None |
returns None |
| Key exists, no expiry set | not reachable, every entry is written with one | returns math.inf |
An expired entry counts as absent, so get_ttl returns None for it, the same
way get does. math.inf only ever comes from Redis: the memory cache writes
every entry with a concrete TTL, so it has no never-expiring keys to report.
So code reads the same on either adapter:
import math
remaining = cache.get_ttl(key)
if remaining is None:
... # no such key
elif remaining == math.inf:
... # never expires
else:
... # seconds remaining
_get_all is a bounded utility, not a paged read
_get_all(key_pattern) returns the entries whose key matches key_pattern, in
key order, the same on every adapter. It is private (the leading underscore) and
deliberately unpaginated. A cache is for point reads by key; enumerating a match
set is not what it is for. On Redis it scans the whole keyspace, so it is a
convenience for tests and small, bounded stores, not a production read path.
count has the same full-scan cost.
It returns at most 1000 entries (GET_ALL_MAX). Past that it truncates to the
first 1000 in key order and logs a warning naming the pattern and the match
count, so a store that outgrew the cache surfaces as a warning rather than a
silent partial read.
To page a large projection set, query the projection's repository
(repository_for(Projection).query), which has indexes and native pagination.
Interface
All cache adapters implement these methods:
| Method | Description |
|---|---|
ping() |
Health check, returns True if cache is accessible |
get_connection() |
Return the underlying cache connection |
add(projection, ttl=None) |
Store a projection with optional TTL override |
get(key) |
Retrieve a projection by key |
_get_all(key_pattern) |
Private. Up to 1000 matching projections, in key order, then it truncates and warns. A bounded utility for tests and small stores, not a production read path |
count(key_pattern) |
Count entries matching a pattern |
remove(projection) |
Remove a cached projection. Does nothing if no record exists for it |
remove_by_key(key) |
Remove an entry by key. Does nothing if the key is absent |
remove_by_key_pattern(key_pattern) |
Remove entries matching a pattern. Does nothing if the pattern matches no keys |
flush_all() |
Remove all entries |
set_ttl(key, ttl) |
Set a TTL on a specific key. Does nothing if the key is absent, but still rejects an invalid TTL |
get_ttl(key) |
Seconds remaining before a key expires; None if there is no such key, math.inf if it never expires. See below |
Key Format
Cache keys follow the pattern {projection_name}:::{identifier}:
order_summary:::ord-123
user_profile:::usr-456
The key_pattern on _get_all, count, and remove_by_key_pattern is a glob.
* matches any run of characters, ? matches one, [...] is a character
class, and other characters are literal. Every entry of one projection is
order_summary:::*. Adapters agree on *, ?, and literal characters; bracket
negation and escaping can differ, so keep patterns to the name:::* shape.
Configuring a cache
- A default cache is required, even if it is only the memory cache for development.
- Set appropriate TTLs: Balance freshness against cache hit rate. Projections that change frequently need shorter TTLs.
- Use Redis in production: The memory cache is not suitable for multi-process deployments or when data must survive restarts.
- Monitor cache health: Use
cache.ping()in your health check endpoints.
Related pages
- Learn about Redis cache for production use
- Understand projections and how they relate to caches
- Learn about projectors that populate caches