Skip to content
Talk to our solutions team

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.

LayerHoldsLivesStatus in the deployed service
Definition cacheFolded schema, connection pool, engine, endpoint and action registries, compiled endpoint scriptsPer (tenant, datastore), in the processAlways on, not configurable
Response cacheThe JSON value a query-kind scripted endpoint returnedOne process-wide store, all tenantsOn per endpoint, via cache.ttl
Entity cache — caches: plus cache: on an entityRows by primary key, result sets by compiled SQLWould be a declared cache backendParses; the hooks are not registered — declaring it changes nothing
Legacy cache.active boot switchesRead at boot, set two flags nothing consumes

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:

TriggerScope
A tenant configuration changeThe tenant’s whole scope — engines, pools, schemas, registries
DELETE /data/cacheThe calling tenant, taken from the CPET headers
DELETE /data/superadmin/tenant/:tenant/cacheOne named sibling tenant
Terminal window
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" }
RouteSuccessErrors
DELETE /data/cache200 {"cleared":true,"tenant":"<cpet>"}403 v2.no_tenant, 500 v2.cache_clear_failed
DELETE /data/superadmin/tenant/:tenant/cache200 {"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.

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" };
}
KeyTypeMeaning
cache.ttldurationEntry lifetime. Absent or 0 means the endpoint is not cached. Negative fails the load.
cache.vary-bylist of stringExtra request headers folded into 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.

ComponentSource
Tenant keyThe four CPET headers
User idThe authenticated principal
PathThe endpoint declaration, not the request URL
Query stringEvery parameter, keys and values sorted
Vary-byEach 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.

  • 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 200 with X-Cache: HIT. A miss sets X-Cache: MISS on the fresh response. An endpoint that declares no positive ttl carries 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.
responsecache:
max_entries: 10000
default_ttl: 30s
KeyDefaultMeaning
responsecache.max_entries10000Entry ceiling for the whole service, across every tenant. Values ≤ 0 keep the default.
responsecache.default_ttl30sDefault 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.

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
KeyTypeValuesRequired
namestringyes
typestringlocal-inmemory, redis, dragonfly, memcached, valkeyno
connectionstringno
ttldurationno
evictionpolicystringlru, lfu, fifo, ttl, randomno
maxmemorystringno
poolsizeintno
serializationstringjson, msgpack, protobufno
prefixstringno

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]
KeyTypeDefaultMeaning
enabledboolfalseMaster switch. Both hooks return immediately unless it is true.
backendstringregistry defaultOne 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.
strategystringreadthroughreadthrough, writethrough / write-through, writebehind / write-behind, refreshahead / refresh-ahead, none.
ttlduration stringCarried on the entity. The store interface the hooks write through takes no per-entry lifetime, so this value is never applied.
scopestringglobalglobal, tenant, user.
invalidateonlist of stringall mutationscreate, update, delete; matched case-insensitively. Empty means every mutation invalidates.
invalidaterelatedlist of stringOther 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 readthrough consults the cache on a read. The other strategies still populate the cache after a read; they never serve from it. Unrecognised strategy and scope values are coerced to readthrough and global rather than rejected.
  • Cached result sets are unreadable under a non-global scope. The write path puts q: before the scope segment and the read path puts it after, so the two key shapes only agree when scope is global. 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.
KeyTypeEffect
cache.activeboolRead at boot. Gates the two below and nothing else.
cache.query.activeboolSets a package flag and logs query cache enabled. No manager is built.
cache.data.activeboolSets a package flag and logs data cache enabled. No manager is built.
enablecacheNot 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.

SurfaceBehaviour
Entity reads over REST and GraphQLNever cached — the entity cache hooks are unregistered
Mutations, bulk operations, actionsNever cached
Scripted endpoints of kind: mutateNever cached, whatever they declare
Compiled SQLRecompiled per request; GET /data/admin/data/hashes is a catalogue of the compiled-query cache and always answers count: 0
Error responsesNever stored
The @cache("5m") query optionParsed 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.