ORM Cache¶
django_cachex.orm caches the results of Django ORM queries on PostgreSQL and SQLite. A write to a table invalidates every cached query that read it, in every process. The app is derived from django-cachalot 2.9.1. For moving from cachalot, see Migrating from django-cachalot.
Add the app to INSTALLED_APPS:
INSTALLED_APPS = [
# ...
"django_cachex.orm",
]
CACHES = {
"default": {
"BACKEND": "django_cachex.cache.ValkeyCache",
"LOCATION": "valkey://127.0.0.1:6379/1",
},
}
CACHEX_ORM = {
"CACHE": "default",
"TIMEOUT": 3600,
}
Every process that writes to the database must run with the app installed.
Caches and databases¶
The ORM cache works with these cache backends:
- The Redis and Valkey backends: standalone, Sentinel and Cluster on redis-py and valkey-py, standalone and Cluster on valkey-glide.
TrackingCacheover one of them. Each process also keeps the results it read in a separate local LRU, bounded by the alias'sMAX_ENTRIES. A local copy saves the transfer but not the round trip, and is never served after a write.LocMemCache, Django's or django-cachex's. It keeps everything in the process, so a write in one process does not invalidate the others. Use it for tests and single-process setups.
Other backends, such as Memcached, DatabaseCache or Django's own RedisCache, cache nothing (cachex_orm.W001).
The cache's serializer must bring results back with their Python types, such as Decimal, datetime and tuples. The default pickle serializer does, and the JSON and MsgPack serializers fail the cachex_orm.E004 check. Any compressor works.
Only PostgreSQL and SQLite databases are cached.
Settings¶
All settings live in the CACHEX_ORM dict, with upper-case keys. An unknown key is the cachex_orm.W004 warning.
| Key | Default | Description |
|---|---|---|
ENABLED |
True |
Serve and store query results. Writes invalidate the cache even while it is False. |
CACHE |
"default" |
Cache alias of the ORM cache. |
DATABASES |
"supported_only" |
Aliases whose queries are cached: "supported_only" for every PostgreSQL and SQLite alias except replicas (aliases with a TEST["MIRROR"]), or a list, tuple or set of aliases. |
TIMEOUT |
the cache's default timeout | Seconds a result is kept. None keeps it until the cache evicts it, even after a write (see Eviction). |
LEASE_TIMEOUT |
60 |
Seconds a write's lease lasts if the write cannot release it (see Failures). |
ONLY_CACHABLE_TABLES |
() |
If set, only queries whose tables are all listed are cached. |
UNCACHABLE_TABLES |
() |
Queries reading one of these tables are not cached, and writes to them invalidate nothing. django_migrations is never cached. |
ADDITIONAL_TABLES |
() |
Tables no model covers, to look for in raw SQL. |
FINAL_SQL_CHECK |
False |
Also search the final SQL of every query for table names in double quotes, such as those in a Func template. |
QUERY_KEYGEN |
"django_cachex.orm.utils.readable_query_key" |
Callable, or its dotted path, that builds the key of a query's result (see Cache keys). |
TABLE_KEYGEN |
"django_cachex.orm.utils.readable_table_key" |
Callable, or its dotted path, that builds the key of a table's generation and leases (see Cache keys). |
What is cached¶
Queries the ORM compiles, such as querysets, get(), count(), aggregate() and their async forms, are cached when all the tables they read are cachable. A result is cached per database, SQL and parameters. The values of an __in filter are sorted before the query runs, so one set of values gets one result in any order, such as the order of the parent rows a prefetch_related() passes. __in filters in subqueries, annotations and FilteredRelation() conditions keep their order.
These are not cached:
select_for_update()andexplain().- Queries ordered by
"?". - Queries calling
Now(),TransactionNow(),Random(),UUID4(),UUID7()orRandomUUID()anywhere, subqueries included. - The results of
iterator()andaiterator(). - Queries holding an expression without
get_source_expressions(). - Queries with a parameter the cache key cannot represent exactly, such as JSON, binary and range parameters under psycopg2.
- Raw SQL through
cursor.execute()andManager.raw(). - Inside a transaction, queries reading a table it has written, unless it reads a snapshot (see Transactions).
The ORM cache does not search SQL you write for functions, so it caches a query with RawSQL("now()") or Func(function="NOW"). Run such queries inside orm_cache_disabled().
How invalidation works¶
Every table has a generation in the cache, and every committed write to the table changes it. A result is stored with the generations of the tables it read and served only while all of them are unchanged.
A write holds a lease on its tables around the statement under autocommit, or around the COMMIT inside a transaction. While a table is leased, queries on it are neither served from the cache nor stored in it. A query running during a write therefore cannot cache a result the write makes stale.
A cached read costs one round trip to the cache and a miss two. A write under autocommit, or the commit of a transaction that wrote, also costs two.
Cache keys¶
A result is stored under orm:{<database alias>}:q:<query key>:<result type>, the generation of a table under orm:{<database alias>}:g:<table key> and its leases under orm:{<database alias>}:l:<table key>, all under the cache alias's KEY_PREFIX and VERSION. The database alias is the hash tag, so on a cluster all keys of one database live on one shard. The keys name database aliases, not databases. Projects or environments sharing a cache server need distinct KEY_PREFIXes or database numbers, or each serves the results the other read.
By default the query key is the sorted names of the tables the query reads, joined with ., then : and the SHA-1 digest of the database alias, SQL and parameters. Past 100 characters, the names that do not fit are left out and counted, as in shop_customer.+3more. The table key is the table name:
orm:{default}:q:shop_customer.shop_order:3f2a9c...:multi
orm:{default}:g:shop_order
orm:{default}:l:shop_order
QUERY_KEYGEN and TABLE_KEYGEN take a callable, or its dotted path, that builds these keys instead. Both are called with keyword arguments and return a str:
QUERY_KEYGEN(*, compiler, tables, digest)returns the query key.compileris theSQLCompilerof the query, already compiled,tablesafrozensetof the names of the tables it reads, anddigestthe SHA-1 hex digest of the database alias, SQL and parameters. The SQL is the SQL that runs, with its__invalues sorted. The ORM cache appends the result type itself.TABLE_KEYGEN(*, db_alias, table)returns the table key, for reads, writes,invalidate()andtable_generations().
A custom keygen must follow these rules:
- A table key depends on
db_aliasandtableonly, and is the same in every process and request. A key that also depends on the tenant of a request lets a write by one tenant leave the other tenants' results of a shared table stale. - Queries with different digests get different keys. Anything a query key adds, such as a tenant id, only splits entries further.
- Tables can share a key. A write to one of them then invalidates the results of all of them.
QUERY_KEYGENcan raisedjango_cachex.orm.utils.UncachableQueryto leave a query uncached. Any other exception from either keygen propagates: a query,invalidate()ortable_generations()fails with it, and a write behaves as one that cannot reach the cache (see Failures).- While processes run with different table keygens, each misses the writes of the others. Run
invalidate_orm_cacheafter they all run the same one.
This table keygen keeps the table keys of django-cachex 0.12.1 and earlier:
# myproject/cache_keys.py
from hashlib import sha1
def hashed_table_key(*, db_alias: str, table: str) -> str:
return sha1(f"{db_alias}:{table}".encode(), usedforsecurity=False).hexdigest()
This query keygen adds the django-tenants schema to the query key, so tenants do not share results (see Limits):
# myproject/cache_keys.py
from django_cachex.orm.utils import readable_query_key
def tenant_query_key(*, compiler, tables, digest):
query_key = readable_query_key(compiler=compiler, tables=tables, digest=digest)
return f"{compiler.connection.schema_name}:{query_key}"
The table keys stay the same in every schema, so a write to a table in one schema invalidates that table's results in all of them. Give the ORM cache an alias without a tenant-aware KEY_FUNCTION such as django_tenants.cache.make_key, which would put the tenant into the table keys too.
Eviction¶
A write does not delete the results it invalidates, so they stay in the cache until they expire or are evicted. What a full Redis or Valkey server evicts depends on its maxmemory-policy:
volatile-*policies evict only keys with an expiry, which only results with aTIMEOUThave. Set aTIMEOUTwith them, or they act likenoeviction.allkeys-*policies also evict generations, which is safe, and leases. An evicted lease acts like one that expired early (see Failures).- Under
noeviction, the default, a full server refuses writes, so every database write to a cached table fails withInvalidationErroruntil memory is freed.
Transactions¶
READ COMMITTED, PostgreSQL's default, and SQLite's default rollback journal let a transaction use the shared cache. Under REPEATABLE READ and SERIALIZABLE, and on SQLite in WAL mode, a transaction reads a snapshot that can be older than the shared cache. It caches up to 16 MiB of pickled results for itself instead, and drops them when it ends.
The ORM cache reads the isolation level per connection, from the isolation_level option or the server, and again after raw SQL changes the session default. Other SQL naming an isolation level, such as SET TRANSACTION ISOLATION LEVEL, makes the connection cache per transaction until it reconnects.
Failures¶
A write that cannot take its lease, for example because the cache server is down, raises django_cachex.orm.exceptions.InvalidationError before the statement or COMMIT runs. The error is a DatabaseError, so atomic() rolls the transaction back. To keep writing through a cache outage, set CACHEX_ORM["ENABLED"] = False. A write that cannot reach the cache then logs a warning and goes ahead. Run invalidate_orm_cache before enabling it again.
If a write cannot release its lease or its process dies, queries on its tables run against the database until the lease expires after LEASE_TIMEOUT. A failed release, lookup or store logs a warning to the django_cachex.orm logger and does not raise.
LEASE_TIMEOUT must outlast the longest write statement and COMMIT. Keep it above the database's statement_timeout. A lease that expires while its write still runs lets another process store what it read before the commit. That result is served until the write releases the lease, or until the result expires if the release fails.
API¶
invalidate()¶
invalidate(*tables_or_models, cache_alias=None, db_alias=None) invalidates the cached queries of the given tables, models or "app_label.ModelName" strings, or of every table when none are given. cache_alias and db_alias narrow it to one cache or database. Call it after changing data the ORM cache cannot see, such as a bulk load through another client.
If a cache cannot be reached, it still invalidates the others, then raises one InvalidationError naming the caches and databases that failed.
orm_cache_disabled()¶
Queries in the block run against the database, and writes in it still invalidate the cache. It applies to the current thread or asyncio task.
table_generations()¶
table_generations(*tables_or_models, db_alias="default") returns the current generations of the given tables as a tuple of strings. Every committed write to one of the tables changes the tuple, so a value computed from the tables can be cached under it:
from django.core.cache import cache
from django_cachex.orm.api import table_generations
def open_order_total():
generations = table_generations(Order, OrderLine)
if generations is None:
return compute_open_order_total()
key = "open-order-total:" + ":".join(generations)
total = cache.get(key)
if total is None:
total = compute_open_order_total()
cache.set(key, total, timeout=3600)
return total
Read the generations before computing the value, and name every table the computation reads. table_generations() returns None when a value computed now must not be cached:
- While a write to one of the tables holds its lease.
- When the ORM cache is disabled or would not cache a query of these tables.
- Inside a transaction that has written one of the tables or reads a snapshot.
Management command¶
python manage.py invalidate_orm_cache # every table
python manage.py invalidate_orm_cache shop # an app's models, many-to-many tables included
python manage.py invalidate_orm_cache shop.Order --cache default --db default
System checks¶
| ID | Meaning |
|---|---|
cachex_orm.W001 |
The cache backend cannot hold the ORM cache, so nothing is cached. |
cachex_orm.W002 |
None of the databases are PostgreSQL or SQLite. |
cachex_orm.W003 |
DATABASES is empty. |
cachex_orm.W004 |
CACHEX_ORM has unknown keys. |
cachex_orm.W005 |
A database listed in DATABASES has a TEST["MIRROR"], so it looks like a replica. |
cachex_orm.E001 |
DATABASES names an alias missing from Django's DATABASES. |
cachex_orm.E002 |
DATABASES is neither "supported_only" nor a list, tuple or set. |
cachex_orm.E003 |
CACHE names an alias missing from CACHES. |
cachex_orm.E004 |
The cache's serializer does not bring query results back unchanged. |
cachex_orm.E005 |
The cache could not be loaded. |
cachex_orm.E006 |
A database listed in DATABASES is neither PostgreSQL nor SQLite. It is not cached. |
cachex_orm.E007 |
A table setting is not a list, tuple or set, like ("django_session") without its comma. The value counts as empty. |
cachex_orm.E008 |
LEASE_TIMEOUT is not a positive number of seconds. |
Limits¶
- The ORM cache does not see writes by other applications and database clients, triggers and rules,
COPY, or stored procedures run withcallproc(). Callinvalidate()orinvalidate_orm_cacheafter them. Deletes follow the foreign keys that Django declares with a database-levelon_delete(DB_CASCADE,DB_SET_NULL,DB_SET_DEFAULT).TRUNCATE ... CASCADEfollows every table that references a truncated one. - Raw SQL writes are recognized by keyword (
INSERT,UPDATE,DELETE,TRUNCATE,ALTER,CREATE,DROP,REFRESH,REPLACE INTO,MERGE INTO) and their tables by name. Tables without a model are found only when listed inADDITIONAL_TABLES. Transactions opened with a rawBEGINare not seen, so useatomic(). - Results read from a view are cached under the view's name, so writes to the tables behind it do not invalidate them. List views in
UNCACHABLE_TABLES, orinvalidate()them after such writes.REFRESH MATERIALIZED VIEWinvalidates the view it names. - A result is keyed by the database alias, SQL and parameters. If the same SQL returns other rows depending on session state, such as django-tenants' per-tenant
search_pathor a row-level security policy reading a session setting, one tenant is served another's rows. Add that state to the query key (see Cache keys). - Leave replicas out of
DATABASES. Writes to the primary do not invalidate what was cached from a replica's alias. - A failover of the cache server can lose the latest generation changes, which makes stale results current again. Run
invalidate_orm_cacheafter a failover. - Writes made while the app was uninstalled, a database was left out of
DATABASESorCACHEnamed another alias did not invalidate the cache. Runinvalidate_orm_cachebefore switching back. migrateinvalidates every model after applying a migration, and so doesflush. Migrations take leases like any write, so they need the cache too, orENABLEDoff followed byinvalidate_orm_cache.