Skip to content

TTL Caching

📖 This page describes the persista.cache package, which provides time-to-live (TTL) caching for values and function calls, with both synchronous and asynchronous APIs.

Prerequisites: You'll need to know a bit of Python, and it helps to be familiar with the store user guide since caches are backed by a BaseStore/AsyncBaseStore.

Overview

The persista.cache package provides two related ways to cache data:

  • TTLCache / AsyncTTLCache: explicit cache objects with get/set/clear methods, backed by any BaseStore/AsyncBaseStore (an in-memory store by default)
  • cached / async_cached: decorators that cache the result of a function call using a shared default cache

Every cached entry has an expiration time. Once a key's TTL has elapsed, get behaves as if the key were never set.

Using TTLCache Directly

Create a TTLCache and use set/get like a dictionary with expiration:

>>> from persista.cache import TTLCache
>>> cache = TTLCache(default_ttl=60)
>>> cache.set("greeting", "hello")
>>> cache.get("greeting")
'hello'
>>> cache.get("missing") is None
True

default_ttl (in seconds) is used whenever set is called without an explicit ttl. Pass ttl to set to override it for a single entry:

>>> from persista.cache import TTLCache
>>> cache = TTLCache()
>>> cache.set("greeting", "hello")
>>> cache.set("short-lived", "value", ttl=30)

clear removes every entry:

>>> from persista.cache import TTLCache
>>> cache = TTLCache()
>>> cache.set("greeting", "hello")
>>> cache.clear()
>>> cache.get("greeting") is None
True

By default, TTLCache stores entries in an InMemoryStore. Pass any other BaseStore to persist cached values, e.g. to share a cache across processes with RedisStore:

from persista.cache import TTLCache
from persista.store import RedisStore

cache = TTLCache(store=RedisStore("redis://localhost:6379/0"), default_ttl=300)

Memoizing Functions with TTLCache.memoize

TTLCache.memoize is a decorator that caches a function's return value, keyed on the function name and its arguments:

>>> from persista.cache import TTLCache
>>> cache = TTLCache()
>>> calls = []
>>> @cache.memoize(ttl=60)
... def square(x):
...     calls.append(x)
...     return x * x
...
>>> square(4)
16
>>> square(4)  # served from the cache, not re-computed
16
>>> calls
[4]

memoize also works on async def functions, in which case it must be used with an AsyncTTLCache (see below).

memoize accepts two options that control how the cache key is computed from a call's arguments, via make_key (see Cache Keys below):

  • strategy: either "pickle" (the default) or "json". "pickle" supports a broader range of argument types, but produces keys that are only stable within a single Python version. "json" produces keys that are stable across Python versions and processes, but requires every argument to be JSON-serializable.
  • ignore_non_serializable: if True, positional arguments and keyword argument values that aren't serializable with strategy are silently dropped before computing the key, instead of raising an error. This is useful when a function takes an argument that will never be serializable (e.g. a logger or a client instance) but shouldn't prevent caching — calls that differ only in that argument then share the same cache entry.
>>> from persista.cache import TTLCache
>>> cache = TTLCache()
>>> calls = []
>>> @cache.memoize(ttl=60, strategy="json", ignore_non_serializable=True)
... def greet(name, client=None):
...     calls.append(name)
...     return f"hello {name}"
...
>>> greet("Ann", client=object())
'hello Ann'
>>> greet("Ann", client=object())  # different (non-serializable) client, still a cache hit
'hello Ann'
>>> calls
['Ann']

Async Caching with AsyncTTLCache

AsyncTTLCache mirrors TTLCache, but every method is a coroutine and it is backed by an AsyncBaseStore (an AsyncInMemoryStore by default):

>>> import asyncio
>>> from persista.cache import AsyncTTLCache
>>> async def main():
...     cache = AsyncTTLCache(default_ttl=60)
...     await cache.set("greeting", "hello")
...     print(await cache.get("greeting"))
...     await cache.clear()
...     print(await cache.get("greeting"))
...
>>> asyncio.run(main())
hello
None

AsyncTTLCache.memoize decorates async def functions:

>>> import asyncio
>>> from persista.cache import AsyncTTLCache
>>> cache = AsyncTTLCache()
>>> calls = []
>>> @cache.memoize(ttl=60)
... async def square(x):
...     calls.append(x)
...     return x * x
...
>>> async def main():
...     print(await square(4))
...     print(await square(4))  # served from the cache, not re-computed
...
>>> asyncio.run(main())
16
16
>>> calls
[4]

Shared Default Caches: cached and async_cached

For simple cases, cached and async_cached avoid creating and threading a TTLCache instance through your code. They use a shared module-level default cache, retrieved with get_ttl_cache / get_async_ttl_cache:

>>> from persista.cache import cached
>>> calls = []
>>> @cached(ttl=60)
... def square(x):
...     calls.append(x)
...     return x * x
...
>>> square(4)
16
>>> square(4)  # served from the cache, not re-computed
16
>>> calls
[4]
>>> import asyncio
>>> from persista.cache import async_cached
>>> calls = []
>>> @async_cached(ttl=60)
... async def square(x):
...     calls.append(x)
...     return x * x
...
>>> async def main():
...     print(await square(4))
...     print(await square(4))  # served from the cache, not re-computed
...
>>> asyncio.run(main())
16
16
>>> calls
[4]

cached and async_cached accept the same strategy and ignore_non_serializable options as TTLCache.memoize (see above), since they compute cache keys the same way:

>>> from persista.cache import cached
>>> calls = []
>>> @cached(ttl=60, strategy="json", ignore_non_serializable=True)
... def greet(name, client=None):
...     calls.append(name)
...     return f"hello {name}"
...
>>> greet("Ann", client=object())
'hello Ann'
>>> greet("Ann", client=object())  # different (non-serializable) client, still a cache hit
'hello Ann'
>>> calls
['Ann']

Use set_ttl_cache / set_async_ttl_cache to replace the shared default cache, for example to change its backend or default TTL globally:

>>> from persista.cache import TTLCache
>>> from persista.cache import get_ttl_cache, set_ttl_cache
>>> set_ttl_cache(TTLCache(default_ttl=60))
>>> get_ttl_cache().default_ttl
60

Cache Keys

Internally, memoize, cached, and async_cached derive a cache key from the function's qualified name and its arguments using make_key, which serializes (func, args, kwargs) with sorted keyword argument keys and hashes the result. Calls with the same arguments (regardless of keyword argument order) map to the same key:

>>> from persista.cache.utils import make_key
>>> make_key("add", (1, 2), {}) == make_key("add", (1, 2), {})
True
>>> make_key("add", (), {"a": 1, "b": 2}) == make_key("add", (), {"b": 2, "a": 1})
True
>>> make_key("add", (1, 2), {}) == make_key("add", (1, 3), {})
False

make_key supports two serialization strategies, selected with strategy:

  • "pickle" (the default): serializes with pickle before hashing. Supports a broader range of argument types (e.g. custom objects, datetimes) than "json", at the cost of a key that is only stable within a single Python version, since pickle's format can change across versions.
  • "json": serializes with json before hashing, so keys are stable across Python versions and processes, but every argument must be JSON-serializable (dict, list, str, int, float, bool, None, and nested combinations thereof).
>>> from persista.cache.utils import make_key
>>> make_key("add", (1, 2), {}, strategy="json") == make_key(
...     "add", (1, 2), {}, strategy="json"
... )
True

By default, an argument that isn't serializable with strategy raises an error when the key is computed — meaning the decorated function can't be called with that argument at all. Pass ignore_non_serializable=True to instead silently drop non-serializable positional arguments and keyword argument values before computing the key:

>>> import threading
>>> from persista.cache.utils import make_key
>>> make_key("add", (1, threading.Lock()), {}, ignore_non_serializable=True) == make_key(
...     "add", (1,), {}, ignore_non_serializable=True
... )
True

This is useful for arguments that will never be serializable (e.g. a logger or a client instance) but that shouldn't block caching. Note that since the argument is dropped rather than incorporated into the key, calls that differ only in such an argument are treated as the same call and share a cached result — make sure that's the behavior you want before enabling it for a given argument.

API Reference

See the reference documentation for the full API.