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 withget/set/clearmethods, backed by anyBaseStore/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: ifTrue, positional arguments and keyword argument values that aren't serializable withstrategyare 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 withpicklebefore 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 withjsonbefore 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.