xtr-cache
Cache pools in memory, in files, in Redis, chained or tag-aware — computed once, even under load.
Why?
A value that is expensive to compute and read often belongs in a cache. Doing that by hand — check, compute on a miss, save — is three steps with a race between each: under load, every request that misses computes the same value at the same moment, and the backend it protects takes the whole stampede at once.
This package implements the xtr-cache-contracts interfaces and makes the backend a constructor argument:
- 🔁 Fetch-or-compute —
await cache.get(key, compute), one call. - 🐘 Stampede protection — concurrent misses share one computation, across processes too, and hot values are refreshed shortly before they expire.
- 🗄️ Six adapters — memory, files, Redis, nowhere, a chain of them, or tag-aware on top of any.
- 🏷️ Tags — invalidate every value carrying a tag at once, whatever its key.
- 🔐 Encryption — values encrypted and authenticated with libsodium, for a shared backend.
- 🧩 A bundle — named pools configured in Python, and
cache:pool:*console commands.
from xtr_cache import FilesystemAdapter, ItemInterface
cache = FilesystemAdapter("app")
async def load_profile(item: ItemInterface) -> Profile:
item.expires_after(3600)
return await profiles.fetch(user_id)
profile = await cache.get(f"profile.{user_id}", load_profile)
Install
uv add xtr-cache
uv add "xtr-cache[redis]" # RedisAdapter, on redis-py's asyncio client
uv add "xtr-cache[sodium]" # SodiumMarshaller, on PyNaCl
uv add "xtr-cache[di]" # the bundle for xtr-dependency-injection
uv add "xtr-cache[console]" # the cache:pool:* commands for xtr-console
Requires Python 3.11+. Depends on xtr-cache-contracts, xtr-clock, xtr-lock and
xtr-logging-contracts.
Every contract symbol is re-exported, not redefined: xtr_cache.CacheInterface is xtr_cache_contracts.CacheInterface, so a library typed against the contract receives these pools
unchanged.
Quick start
Two levels, most code needing the first.
Fetch-or-compute. Hand the cache the key and what computes the value; it decides when to call it. The callback sets the value's lifetime — and tags, on a tag-aware pool — through the item it receives:
async def load_invoice(item: ItemInterface) -> Invoice:
item.expires_after(timedelta(minutes=10))
return await invoices.fetch(invoice_id)
invoice = await cache.get(f"invoice.{invoice_id}", load_invoice)
await cache.delete(f"invoice.{invoice_id}")
Items. When a hit must be told apart from a miss, several keys read at once, or writes batched:
item = await cache.get_item("rate.42")
if not item.is_hit():
await cache.save(item.set(0).expires_after(60))
items = await cache.get_items(["a", "b", "c"]) # every key, hit or miss, in order
await cache.save_deferred(item) # queued ...
await cache.commit() # ... stored in one batch
Keys are non-empty strings without any of {}()/\@:. None is a value like any other. A pool
never raises because its backend failed: reads miss, writes return False, and the pool logs why
through its logger (pool.set_logger(...)).
Adapters
| Adapter | Keeps values | Shared with | Prunes |
|---|---|---|---|
ArrayAdapter |
in this process's memory | nobody | — |
FilesystemAdapter |
one file each, under a directory | processes on this machine | ✓ |
RedisAdapter |
on a Redis or Valkey server | anything reaching the server | — |
NullAdapter |
nowhere: every read misses | — | — |
ChainAdapter |
in several pools, fastest first | whatever its pools share | ✓ |
TagAwareAdapter |
in any pool, with tag versions | whatever its pools share | ✓ |
Every adapter takes a default_lifetime in seconds, applied when an item sets no expiry (0
keeps values until deleted), and a namespace its keys live under. pool.with_sub_namespace("t")
returns a view whose keys live under t inside it — cleared together, left alone by the parent's
other keys.
AdapterFactory.create_adapter(...) builds any of them from what a configuration names:
| Given | Adapter |
|---|---|
"array", "null" |
ArrayAdapter, NullAdapter |
"filesystem", "filesystem:///var/cache/app" |
FilesystemAdapter, in the directory given |
"redis://…", "rediss://…", "unix://…", "valkey://…", "valkeys://…" |
RedisAdapter owning its connection |
| an asyncio Redis client | RedisAdapter on that client |
ArrayAdapter
A dictionary. Values are stored serialized by default, so what a caller gets back is a copy it can
change freely; store_serialized=False stores the objects themselves. max_items drops the least
recently used beyond a count, max_lifetime caps every value's lifetime.
FilesystemAdapter
One file per value under directory/namespace, holding its expiry, its key and its bytes. A write
goes to a temporary file first and replaces the value's file in one step, so readers never see
half a value. File work runs on a worker thread, off the event loop. Nothing is created until the
first write. An expired file is removed when read; await pool.prune() removes the rest.
Given no directory, a pool uses xtr-cache in the system's temporary directory, which any user
may create first. Values are unpickled when read, so that directory is made readable by its owner
alone, and one belonging to another user is refused: reads miss, writes fail, and the pool logs
why. A directory you give is used as it is. Where the system has no user ids to compare, as
on Windows, neither check applies: the directory is created, but its owner is not checked.
RedisAdapter
from redis.asyncio import Redis
from xtr_cache import RedisAdapter
pool = RedisAdapter(Redis.from_url("redis://cache:6379/0"), "app") # your client, you close it
pool = RedisAdapter.from_url("redis://cache:6379/0", "app") # its own client ...
await pool.close() # ... which it closes
Each value is a string key, namespace:key, expired by the server itself. Reads fetch many keys
in one round trip, writes are pipelined, and clearing scans the namespace and unlinks its keys in
batches — so a namespace is required: without one, clearing would empty the database. A client
built from a DSN gives up on a server silent for 5 seconds — a miss, or a
failed write — unless ?socket_timeout= and ?socket_connect_timeout= in the DSN say otherwise.
One server; not a cluster, not Sentinel.
ChainAdapter
cache = ChainAdapter([ArrayAdapter(), RedisAdapter.from_url("redis://cache", "app")])
Reads ask each pool in turn. A value found in a slower one is copied into the faster ones, with
the life it has left — every expiring value is stored with its expiry, so a copy never outlives
the original — or default_lifetime when it was stored to live forever. Writes, deletes
and clears reach every pool.
TagAwareAdapter
cache = TagAwareAdapter(RedisAdapter.from_url("redis://cache", "app"))
async def load_order(item: ItemInterface) -> Order:
item.tag([f"customer.{customer_id}", "orders"])
return await orders.fetch(order_id)
await cache.get(f"order.{order_id}", load_order)
await cache.invalidate_tags([f"customer.{customer_id}"]) # every order of that customer
Each tag has a version, kept in the tags pool — the items pool unless another is given. An item is
saved with its tags' versions and is a hit only while every one is unchanged; invalidating a tag
deletes its version. Nothing is listed or scanned, so invalidating costs the same however many
items carry the tag. Versions read are trusted for known_tag_versions_ttl seconds (0.15 by
default), which is how soon an invalidation elsewhere reaches this process. Items saved as
deferred take their versions when committed, so an invalidation meanwhile does not apply to them.
A tag's first version is given one save at a time in a process; two processes giving one at the
same moment may each keep their own, and the items of the one that lost miss once and are
computed again.
Tagging an item of any other pool raises LogicError.
Stampede protection
get() protects the backend three ways:
-
One computation per key in a process. Concurrent misses on one key share the first caller's computation — and its result: they all get the very object computed, so copy it before changing it; what is cached is not affected. If it fails or is cancelled, the others compute for themselves, side by side.
-
One computation per key across processes, with a
LockRegistry. Keys are spread overslotslocks (20 by default); the caller that takes a key's slot computes and saves, the others wait for it and read what was saved — or compute themselves afterwaitseconds (30), so a stuck holder never stalls every reader, and stop using that slot from then on. Slots are chosen by the pool's namespace and the key, so two pools never wait on each other's keys. The lock store decides who shares the locks: file locks for every process on a machine, Redis for every machine.from xtr_lock import LockFactory, RedisStore registry = LockRegistry(LockFactory(RedisStore.from_url("redis://cache"))) cache.set_lock_registry(registry)
-
Early recomputation. A computed value is stored with how long it took and when it expires — its own lifetime, or the pool's default. On a hit, the value may be recomputed before it expires, with a chance that grows as expiry nears and faster for values that are slow to compute. Under load one caller refreshes it while the others keep reading the old one, instead of all missing at once.
betatunes it:0disables it,math.infforces a recomputation now.
A callback reading its own key — directly or through what it calls — computes without saving
rather than waiting on itself. Time comes from the clock in force (xtr_clock), so
mock_time() freezes lifetimes and recomputation alike.
Marshallers
Adapters that store bytes — files, Redis, and memory when serializing — encode values with a marshaller, pickle by default: a cache reads values back without being told their type, and pickle is the format that carries it. A dataclass comes back a dataclass.
| Marshaller | Does |
|---|---|
DefaultMarshaller |
pickle; any object that pickles round-trips |
DeflateMarshaller(inner) |
compresses what inner encodes; reads uncompressed bytes too |
SodiumMarshaller(keys, inner=None) |
encrypts and authenticates what inner encodes |
Unpickling runs code named by the bytes, which is safe only while nothing else can write to the
backend. On a Redis server shared with other applications, use SodiumMarshaller: values are
encrypted with libsodium's authenticated secret-key encryption, so they can be neither read nor
forged without a key, and bytes anyone else wrote are refused — read as a miss — before pickle
ever sees them.
from xtr_cache import RedisAdapter, SodiumMarshaller
key = SodiumMarshaller.generate_key() # once; keep it in a secret, base64-encoded
pool = RedisAdapter.from_url("redis://cache", "app", marshaller=SodiumMarshaller([key]))
Keys rotate: the first encrypts, every one decrypts. Put the new key first and keep the old one until what it encrypted has expired.
Use in an application
Everything adding this package to an application on xtr-dependency-injection takes — and, read backwards, what removing it undoes.
- Install —
uv add "xtr-cache[di]"; addredis,sodium,consoleorloggingfor what you use. - Recipe —
uv run xtr-recipes recipes:syncdoes the Activate and Ignore steps below: it listsCacheBundleand ignores/var/. There is no config file or environment to write. - Activate —
CacheBundle: {"all": True}inBUNDLESin<app>/bundles.py, imported fromxtr_cache.bundle. - Brings along — the logging and console bundles, when those packages are installed.
- Configure — optional: with no configuration there is one
apppool on files. Pools, adapters and the stampede lock go in<app>/config/cache.py, a@configurefunction returningCacheConfig— see Kernel / bundle. - Environment — nothing required. A DSN given as
env(...)must be set when the application boots: booting checks every pool. - Ignore —
var/: files live underkernel.share_dir,var/sharein the project. - Remove — drop the
BUNDLESentry, delete<app>/config/cache.py, thenuv remove xtr-cache. - Check —
debug:bundlesshowscacheaslistedandactive.
Kernel / bundle
With xtr-dependency-injection, list the bundle and name the pools:
# app/bundles.py
from xtr_cache.bundle import CacheBundle
BUNDLES = {CacheBundle: {"all": True}}
# app/config/cache.py
from xtr_dependency_injection import configure, env
from xtr_cache.bundle import CacheConfig, PoolConfig
@configure
def cache() -> CacheConfig:
return CacheConfig(
app=env("CACHE_DSN"),
pools={
"sessions": "redis://cache:6379/1",
"catalogue": PoolConfig(adapter=["array", "redis://cache:6379"], tags=True),
"reports": PoolConfig(default_lifetime=3600), # the app pool's adapter
},
stampede_lock="redis://cache:6379/2",
)
Every pool is registered under AdapterInterface, CacheInterface, CacheItemPoolInterface
and NamespacedPoolInterface — plus TagAwareCacheInterface and TagAwareAdapterInterface for
a pool with tags=True — qualified by its name. The app pool is also provided without a
qualifier.
from typing import Annotated
from xtr_dependency_injection import Target, as_service
from xtr_cache_contracts import CacheInterface, TagAwareCacheInterface
@as_service
class Catalogue:
def __init__(
self,
cache: CacheInterface, # the app pool
products: Annotated[TagAwareCacheInterface, Target("catalogue")],
) -> None: ...
CacheConfig field |
Meaning |
|---|---|
app |
The app pool's adapter, or several to chain. "filesystem" by default |
pools |
Every other pool: an adapter, several, or a PoolConfig(adapter, default_lifetime, tags, namespace). A pool without adapter uses app's. tags=True keeps tag versions in the pool itself; tags="other" keeps them in the pool named other |
directory |
Where "filesystem" keeps files: "%kernel.share_dir%/cache" by default, var/share/cache in the project |
prefix_seed |
What each pool's namespace is derived from, with its name: "%kernel.project_dir%" by default, so two applications on one backend never meet; give two the same seed to share values |
stampede_lock |
Where stampede locks go — any lock DSN: "flock://%kernel.share_dir%/cache/locks" by default, "redis://…" to span machines — or None for per-process protection only |
An adapter entry is a DSN or keyword AdapterFactory reads — env(...) included — or a
Reference(Redis, "cache") (from xtr_dependency_injection) to a client the container already
provides, which the application keeps closing.
-
Zero config: one
apppool on files. Nothing is opened until a pool is first asked for. -
Checked at boot: booting reads the configuration,
env()values included, and refuses a DSN no adapter serves, a Redis DSN without theredisextra, aReferencethe container does not provide, or a stampede lock no store serves — naming the pool, never a DSN's credentials. -
Marshaller: pools encode with the
MarshallerInterfaceservice, pickle by default. Register your own to replace it:@as_service def marshaller(key: Annotated[str, Autowire(env="CACHE_DECRYPTION_KEY")]) -> MarshallerInterface: return SodiumMarshaller([key])
-
Lifecycle: between messages the kernel's resetter commits what each pool deferred; when the container closes, pools commit and close the connections they opened.
-
Logging: when the logging bundle is active, a
cachechannel is added and every pool logs there — backend failures at warning, stampede waits at info. -
Console: when the console bundle is active, the commands below are registered.
Console commands
| Command | Does |
|---|---|
cache:pool:list |
Lists the pools by name |
cache:pool:clear POOL… [--all] [--exclude POOL] |
Clears the named pools, or all of them |
cache:pool:delete POOL KEY |
Deletes one item |
cache:pool:invalidate-tags TAG… [-p POOL] |
Invalidates tags in the named pools, or every tag-aware one |
cache:pool:prune |
Removes expired values from every pool that keeps them |
Without a container, hand them pools once, and import xtr_cache.command before the application
runs:
from xtr_cache import CachePoolClearer
from xtr_cache.command import use_pools
use_pools(CachePoolClearer({"app": cache, "sessions": sessions}))
Errors
Every error derives from the contract's CacheError and carries what went wrong as reason. A
backend failing is never one of them: it is logged, and the call misses or returns False.
| Error | Raised when |
|---|---|
InvalidArgumentError |
A key, tag, namespace, beta, DSN or option is invalid (also a ValueError) |
LogicError |
An item of a pool without tags is tagged |
MarshallingError |
A marshaller cannot decode stored bytes — caught by pools, which read a miss |
Development
Developed in the python-xtr monorepo, under
packages/xtr-cache; run the commands below from there. The python-xtr-cache repository is a
read-only copy, so send issues and pull requests to the monorepo.
uv sync
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
The suite reaches no server: the Redis adapter runs against a fake client kept in memory, and
every adapter that stores values — all but NullAdapter, which stores none — passes the same
conformance tests.
License
MIT — see LICENSE.
Metadata
Release files for xtr-cache 4.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| xtr_cache-4.0.3.tar.gz | 66.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_cache-4.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 149.6 kB
Release files / xtr_cache-4.0.3.tar.gz
| Download URL | xtr_cache-4.0.3.tar.gz |
|---|---|
| Size | 66.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
daf767cf549e0c657e342c0f51ab7aee428dfe69debaf09ba3f7f613bbf4bafb
|
|
BLAKE2b-256 checksum How to use checksums |
0dc05b2499a444a60749c7a83e71ac5c47ab869bea81c440113866410d552be6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / xtr_cache-4.0.3-py3-none-any.whl
| Download URL | xtr_cache-4.0.3-py3-none-any.whl |
|---|---|
| Size | 83.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f6aa477c6fa2c61febd042b899cce6cd5aa9f495ad9882c2be06cdc6d246bb69
|
|
BLAKE2b-256 checksum How to use checksums |
fc26e16606e80317882ce70ec04cafa1395032052a17a2fd2b26bfd046d15123
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency log