Caching
data.svc caches three things: the definitions it compiles per tenant, the responses of scripted
endpoints that ask for it, and the rows of entities that declare cache:. Every statement is still
compiled on the request that runs it; what the entity cache saves is running it.
The layers
Section titled “The layers”| Layer | Holds | Lives | Status in the deployed service |
|---|---|---|---|
| Definition cache | Folded schema, connection pool, engine, endpoint and action registries, compiled endpoint scripts | Per (tenant, datastore), in the process | Always on, not configurable |
| Response cache | The JSON value a query-kind scripted endpoint returned | One process-wide store, all tenants | On per endpoint, via cache.ttl |
Entity cache: caches: plus cache: on an entity | The rows a read scanned, keyed by the compiled statement | A declared backend: in the process, in Dragonfly, or both | On per entity, via cache:; see The entity cache |
The definition cache
Section titled “The definition cache”The first request for a (tenant, datastore) pair fetches the definitions, folds the layers, compiles
one schema, opens the connection pool and builds the engine. Every later request reads that result.
Scripted endpoints compile on first invoke and the compiled plugin is kept; a compile error is kept
too, and is returned to every subsequent call until the entry is dropped.
Three things drop it:
| Trigger | Scope |
|---|---|
| A tenant configuration change | The tenant’s whole scope: engines, pools, schemas, registries |
DELETE /data/cache | The calling tenant, taken from the CEPT headers |
DELETE /data/superadmin/tenant/:tenant/cache | One named sibling tenant |
curl -X DELETE https://api.example.com/data/cache -H "Authorization: Bearer $TOKEN" -H "X-Customer: acme" -H "X-Product: erp" -H "X-Env: prod" -H "X-Tenant: main"{ "cleared": true, "tenant": "acme:prod:erp:main" }| Route | Success | Errors |
|---|---|---|
DELETE /data/cache | 200 {"cleared":true,"tenant":"<cept>"} | 403 no_tenant, 500 cache_clear_failed |
DELETE /data/superadmin/tenant/:tenant/cache | 200 {"invalidated":"<cept>"} | 400 missing_tenant, 400 invalid_tenant, 500 invalidate_failed |
The superadmin route takes the bare tenant segment, not the full four-part key. Neither route touches the response cache described below.
Dropping the definition cache is how a definition change reaches traffic; it does not change the database. Schema changes still need an explicit migration plan.
Scripted-endpoint response cache
Section titled “Scripted-endpoint response cache”A scripted endpoint opts in with a cache: block. Only kind: query endpoints are cacheable, and only
with a positive ttl. A mutate endpoint is never cached, whatever it declares.
endpoints: - path: /reports/daily-summary kind: query verb: GET cache: ttl: 30s vary-by: [header:Accept-Language] script: language: javascript function: main script: | function main(ctx) { return { locale: ctx.req.headers["Accept-Language"] || "en" }; }| Key | Type | Meaning |
|---|---|---|
cache.ttl | duration | Entry lifetime. Absent or 0 means the endpoint is not cached. Negative fails the load. |
cache.vary-by | list of string | Extra request headers folded into the key. |
The key
Section titled “The key”Every component is hashed together with SHA-256 and stored under a prefix reserved for response-cache entries, distinct from anything else in the same store. Nothing in the service drops that prefix, see below.
| Component | Source |
|---|---|
| Tenant key | The four CEPT headers |
| User id | The authenticated principal |
| Path | The endpoint declaration, not the request URL |
| Query string | Every parameter, keys and values sorted |
| Vary-by | Each listed request header, canonicalized and sorted |
Each vary-by entry is read as a request header name; a leading header: is stripped if present. An
entry that names no header, vary-by: [tenant, user], say, contributes an empty value on every
request and so changes nothing. Tenant and user are already components of the key.
vary-by reads the raw request header, so it can key on a header the script itself cannot see:
ctx.req.headers exposes only Accept-Language, Content-Type, Idempotency-Key, X-Request-Id
and User-Agent.
Behaviour
Section titled “Behaviour”- The lookup runs after the auth and rate-limit gates, so a cached payload only reaches a request that has already been proven entitled to it.
- A hit returns
200withX-Cache: HIT. A miss setsX-Cache: MISSon the fresh response. An endpoint that declares no positivettlcarries neither header. - Only a successful invoke populates the cache. Errors are not stored.
- Entries leave by TTL expiry, by oldest-first eviction once the entry ceiling is reached, or when the process restarts, a background sweep reclaims expired entries every 30 seconds.
- When no cache is configured, every request is a miss and the script runs: caching is an optimisation, never a correctness dependency.
Boot configuration
Section titled “Boot configuration”responsecache: max_entries: 10000 default_ttl: 30s| Key | Default | Meaning |
|---|---|---|
responsecache.max_entries | 10000 | Entry ceiling for the whole service, across every tenant. Values ≤ 0 keep the default. |
responsecache.default_ttl | 30s | Default lifetime of the underlying store. |
Both are read once, at boot; there is no per-tenant sizing. default_ttl has no effect on endpoint
responses. Each entry is written with its endpoint’s own TTL, and an endpoint that declares cache:
without a ttl is not cached at all.
The entity cache
Section titled “The entity cache”An entity that declares cache: runs a read’s statement once and answers the same read from the
cache until the entity changes. The response says so: meta.cached: true on a hit, and the
statement log counts no statement
for it.
Backends are declared once under the top-level caches: key, a sibling of entities:, and
referenced by name from an entity:
caches: - name: local type: local ttl: 5m maxentries: 10000 - name: shared type: tiered ttl: 15m coherence: 1s poolsize: 16
entities: - name: currency cache: backend: shared ttl: 1h scope: tenant invalidateon: [create, update, delete] invalidaterelated: [price_list]Backends
Section titled “Backends”type | Entries live | Coherence across instances |
|---|---|---|
local | In this process. Each instance has its own store. | None. An instance sees its own writes at once and another instance’s when the entry expires. |
shared | In one Redis-protocol server every instance points at: Dragonfly, Redis or Valkey. | At once. One store, one set of generations. |
tiered | In this process, backed by the server. | Within coherence (default 1s): hits are local, generations are shared and a local copy is trusted for that long. |
| Key | Type | Meaning |
|---|---|---|
name | string | Required. What an entity’s backend: names. |
type | local | shared | tiered | Required. The type names the role, not the product: dragonfly, redis and valkey are accepted spellings of shared, and local-inmemory of local. |
ttl | duration | Default entry lifetime. 5m when absent. |
maxentries | int | Bound of the local store (local, tiered). Unbounded when absent; refused on shared. |
coherence | duration | tiered only: how long a local copy of a generation is trusted. Refused on the other types. |
poolsize | int | Connections to the server. |
prefix | string | Key namespace in the server. seshat when absent. |
connection | string | A redis:// URL for a development setup. Deployments connect through the boot document instead, so a schema carries no credentials. |
The boot document names where a shared store is, per cache name:
caches: shared: { url: "redis://dragonfly:6379/0", poolsize: 16 }A shared or tiered cache with neither a boot entry nor a URL fails the boot, naming the
cache. A server that does not answer at boot fails it too.
The entity block
Section titled “The entity block”| Key | Type | Default | Meaning |
|---|---|---|---|
backend | string | the only declared cache | A declared cache name. Required when more than one is declared; a name that is not declared fails the load. |
ttl | duration | the backend’s | Entry lifetime for this entity. |
scope | tenant | user | tenant | tenant shares entries among the tenant’s callers; user keys them by caller as well. |
invalidateon | list | every write | Which operations orphan the entries: create, update, delete, purge, restore. |
invalidaterelated | list | — | Entities whose entries a write to this one orphans too. Each must be cached itself. |
enabled | bool | true | false keeps the block and turns the cache off. |
A remote entity cannot be cached. The old strategy, evictionpolicy, maxmemory and
serialization keys are refused: only read-through is served.
What a hit carries
Section titled “What a hit carries”Rows as the engine scanned them, before the read-side hooks. Masking, unmasking, hiding, decryption, enum labels and computed fields run on every hit for the caller at hand, so a cached row never carries one caller’s view to another; an encrypted column is encrypted in the cache too. Row-level security is part of the statement the key hashes, so callers with different predicates never share an entry. A hit is a fresh copy, typed as the scan typed it.
The key and invalidation
Section titled “The key and invalidation”An entry is keyed by the tenant, two generations (the tenant’s and the entity’s), the entity, the
caller under scope: user, and a hash of the compiled statement and its arguments. A write bumps
the entity’s generation with one increment and orphans every entry at once; nothing is scanned or
deleted, an orphaned entry ages out by its TTL. invalidaterelated bumps the named entities’
generations too. DELETE /data/cache and a tenant configuration change bump the tenant’s.
The generation a read observed before its statement is the one its rows are stored under, so a write that lands between the statement and the store orphans the late store instead of leaving stale rows behind. Inside a transaction the engine never reads the cache, and a transaction’s writes invalidate after the commit, never before.
What bypasses it
Section titled “What bypasses it”Reads inside a transaction, locking reads (FOR UPDATE, FOR SHARE), reads with include, the
engine’s own reads (reference checks, seeds) and remote entities run their statement every time.
What invalidates
Section titled “What invalidates”Create, update, delete, purge and restore through the engine: REST, GraphQL, a script’s data.*
calls, bulk import, versioned and audited entities, and declared actions that write (by the
action’s entity). Writes that bypass the engine, the retention runner and raw SQL, do not; their
rows age out by TTL.
Counters
Section titled “Counters”GET /admin/data/cache/stats reports hits, misses, stores, invalidations and errors per backend
since the process started. errors counts the cache failing, not a caller leaving: a client that
disconnects mid-request cancels its context and is not counted.
What it saves
Section titled “What it saves”Measured on one machine, twenty-five concurrent callers, each entity against an identical one with no cache block, every run back to back. Treat the ratios as the result and the absolute rates as a property of that laptop.
| Read | local | tiered | shared |
|---|---|---|---|
| Point read by primary key | 1.8× | 1.7× | 1.2× |
| Filtered list of five rows | 2.3× | 2.2× | 1.6× |
The heavier the statement, the more a hit is worth: a hit costs the same whatever it replaced. A
local hit still carries the rest of the request: routing, authentication, the hook chain,
rendering, which is the floor those ratios sit on. A shared hit trades a database round trip
for one to the cache server, so it gains least on the cheapest reads.
Legacy boot switches
Section titled “Legacy boot switches”The cache.active, cache.query.active and cache.data.active switches once set two flags
nothing consumed. The cache: block is now refused at boot: the entity cache is declared in the
schema and connected through the boot document’s caches: block. enablecache was never a key.
What is not cached
Section titled “What is not cached”| Surface | Behaviour |
|---|---|
| Entity reads over REST and GraphQL | Cached only for entities that declare cache:; see above |
| Mutations, bulk operations, actions | Never cached |
Scripted endpoints of kind: mutate | Never cached, whatever they declare |
| Compiled SQL | Recompiled per request; GET /data/admin/data/hashes is a catalogue of the compiled-query cache and always answers count: 0 |
| Error responses | Never stored |
The @cache("5m") query option | Parsed and round-tripped by the query encoders; nothing reads it |
A second mechanism that trades freshness for read cost is materialization: a computed
field declared compute-strategy: materialized is written to its column on every create and update
of the owning row rather than evaluated on every read. See
Materialized views and refresh.
For the read path these caches would sit in front of, see Query DSL and Request flags. Endpoint shapes and status codes are in the Data API reference.