Caching
data.svc caches two things: the definitions it compiles per tenant, and the responses of scripted
endpoints that ask for it. It does not cache rows. Every entity read reaches the database, and every
statement is compiled on the request that runs 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 | Rows by primary key, result sets by compiled SQL | Would be a declared cache backend | Parses; the hooks are not registered — declaring it changes nothing |
Legacy cache.active boot switches | — | — | Read at boot, set two flags nothing consumes |
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 CPET 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:erp:prod:main" }| Route | Success | Errors |
|---|---|---|
DELETE /data/cache | 200 {"cleared":true,"tenant":"<cpet>"} | 403 v2.no_tenant, 500 v2.cache_clear_failed |
DELETE /data/superadmin/tenant/:tenant/cache | 200 {"invalidated":"<cpet>"} | 400 v2.missing_tenant, 400 v2.invalid_tenant, 500 v2.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 CPET 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.
- The layer fails open: if the cache is not configured or fails to build, every request is a miss and the script runs.
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 block
Section titled “The entity cache block”Backends are declared once under the top-level caches: key — a sibling of entities:, not part of
a datastore declaration — and referenced by name from
an entity.
caches: - name: local type: local-inmemory ttl: 5m - name: shared type: redis connection: "redis://redis:6379/0" ttl: 15m evictionpolicy: lru maxmemory: 512mb| Key | Type | Values | Required |
|---|---|---|---|
name | string | — | yes |
type | string | local-inmemory, redis, dragonfly, memcached, valkey | no |
connection | string | — | no |
ttl | duration | — | no |
evictionpolicy | string | lru, lfu, fifo, ttl, random | no |
maxmemory | string | — | no |
poolsize | int | — | no |
serialization | string | json, msgpack, protobuf | no |
prefix | string | — | no |
The loader copies every one of these through as a string. type, evictionpolicy and
serialization are not validated, so a typo is not a load error.
An entity opts in with its own block:
entities: - name: currency cache: enabled: true backend: "local,shared" strategy: readthrough ttl: 1h scope: tenant invalidateon: [create, update, delete] invalidaterelated: [price_list]| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Master switch. Both hooks return immediately unless it is true. |
backend | string | registry default | One name, or several comma-separated, written through in the order given. An unknown name silently falls back to the default store. The read hook looks the whole string up as a single name, so a multi-backend entity reads from the default store. |
strategy | string | readthrough | readthrough, writethrough / write-through, writebehind / write-behind, refreshahead / refresh-ahead, none. |
ttl | duration string | — | Carried on the entity. The store interface the hooks write through takes no per-entry lifetime, so this value is never applied. |
scope | string | global | global, tenant, user. |
invalidateon | list of string | all mutations | create, update, delete; matched case-insensitively. Empty means every mutation invalidates. |
invalidaterelated | list of string | — | Other entity names whose cached rows and queries are purged when this entity mutates. |
Keys are namespaced: a fixed prefix, then a scope segment (empty for global, the tenant for tenant,
the tenant and user for user), then the entity name, then the row’s primary key. Cached result sets
carry an extra q: segment and a hash of the compiled SQL and its arguments in place of the key. The
built-in prefix defaults to an internal name that is scheduled for rename — do not build tooling that
greps for it.
Four behaviours are worth knowing before the block is ever wired:
- Only
readthroughconsults the cache on a read. The other strategies still populate the cache after a read; they never serve from it. Unrecognisedstrategyandscopevalues are coerced toreadthroughandglobalrather than rejected. - Cached result sets are unreadable under a non-
globalscope. The write path putsq:before the scope segment and the read path puts it after, so the two key shapes only agree whenscopeisglobal. Row-by-primary-key entries agree at every scope. - Invalidation is prefix-wide, not row-scoped. One row update drops every cached row and every
cached query for that entity within the scope, plus both prefixes for every entity named in
invalidaterelated. - Related invalidation is one hop. It purges the entities you list, not their relations.
Legacy boot switches
Section titled “Legacy boot switches”| Key | Type | Effect |
|---|---|---|
cache.active | bool | Read at boot. Gates the two below and nothing else. |
cache.query.active | bool | Sets a package flag and logs query cache enabled. No manager is built. |
cache.data.active | bool | Sets a package flag and logs data cache enabled. No manager is built. |
enablecache | — | Not a key. It appears in shipped example profiles and has no reader anywhere. |
No code reads the two flags. Turning these on changes a log line and nothing about request handling.
What is not cached
Section titled “What is not cached”| Surface | Behaviour |
|---|---|
| Entity reads over REST and GraphQL | Never cached — the entity cache hooks are unregistered |
| 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 |
The mechanism that does trade freshness for read cost, and does run, 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.