Cache

from craft.facades import Cache

Cache.put("stats", value, ttl=300)
Cache.get("stats", default=None)
Cache.has("stats")
Cache.forget("stats")
Cache.flush()

Stores

Driver Where Use it for
array Process memory Default; tests and single-process development
file storage/framework/cache Survives a restart, no extra service
redis Redis Shared across processes and machines
CACHE_DRIVER=array

Redis needs the extra:

pip install -e ".[redis]"

If Redis is configured but unreachable, the manager degrades to the array store rather than failing the request. Cache misses are cheaper than downtime — but it does mean a misconfigured Redis looks like a very cold cache.

remember

Compute a value once and reuse it until it expires:

def dashboard_stats():
    return Cache.remember("dashboard.stats", 300, lambda: expensive_query())

The callback runs only on a miss. remember_forever skips the TTL.

A callback that returns None is cached too — remember stores a sentinel in its place, so an expensive lookup that legitimately produced None is not re-run on every call.

Counters

Cache.increment("page.views")        # 1
Cache.increment("page.views", 5)     # 6
Cache.decrement("page.views", 2)     # 4

A missing key starts at zero. Incrementing or decrementing an existing entry keeps whatever TTL it already had — a counter stored with a 60-second TTL does not become eternal just because it was bumped.

pull

Read and forget in one step — handy for one-shot values:

token = Cache.pull("one_time_token")

TTL

ttl is in seconds. Omit it, or use forever, to store without expiry:

Cache.put("key", value, ttl=60)
Cache.forever("key", value)

Expired entries are removed on read, so an expired key behaves exactly like a missing one.

What can be cached

The array store keeps the object as-is. The file and Redis stores serialise to JSON, so values must be JSON-serialisable — dicts, lists, strings, numbers, booleans and None. Model instances are not; cache model.to_dict() instead.

Clearing

python dev.py cache clear

Flushing by prefix

Cache.flush() empties the whole store. To drop one family of keys and keep the rest, name the keys with a shared prefix and flush that prefix:

Cache.put("page:1", first_page, ttl=300)
Cache.put("page:2", second_page, ttl=300)

Cache.flush_prefix("page:")   # page:1 and page:2 are gone; other keys stay

flush_prefix is implemented for the array, file and redis stores.

Bounding the in-memory store

The array store holds at most CACHE_MEMORY_MAX_ENTRIES entries (default 1000), read from cache.stores.memory.max_entries in config/cache.py. When a write goes over the bound, expired entries are dropped first and then the oldest ones, so a long-lived worker that caches per key cannot grow without limit. An evicted entry is a cache miss, never an error.

CACHE_MEMORY_MAX_ENTRIES=1000   # 0 = unbounded

Constructing the store directly takes the same bound: ArrayStore(max_entries=500); without the argument it is unbounded.

In tests

The array store is the default under pytest, and each CacheManager holds its own store, so tests do not leak cached values into each other. To be explicit:

from craft.cache.manager import ArrayStore, CacheManager

cache = CacheManager()
cache._store = ArrayStore()

Atomic put-if-absent

Cache.add(key, value, ttl) stores only when the key is absent and returns whether this caller won. Reach for it instead of has() then put() whenever the answer decides who does the work: the pair is a race, and every guarantee built on it silently does not hold.

Idempotent writes

The cache backs the IdempotencyKey middleware. With IDEMPOTENCY_ENABLED=true a write carrying an Idempotency-Key header has its response stored for IDEMPOTENCY_TTL seconds, and a retry of the same key is answered from the store instead of running the handler again:

curl -X POST https://app.example.com/payments \
  -H 'Idempotency-Key: 0f2b7c1e-...' -H 'Content-Type: application/json' \
  -d '{"amount": "19.90"}'

The key is scoped to the tenant and the authenticated user, so one caller's key can never replay another's response. The first request claims the key with Cache.add(); a retry that arrives while it is still running gets a 409, and the claim is released when the handler finishes (or fails). A 5xx response is not stored - a retry after a transient failure must be able to run again.

On more than one worker the store must be shared: the array store is per-process, so a retry that lands on another worker would run the write a second time. Use redis (or a shared file store) when the app runs behind a load balancer.