Skip to content
Talk to our solutions team

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.

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 entityThe rows a read scanned, keyed by the compiled statementA declared backend: in the process, in Dragonfly, or bothOn per entity, via cache:; see The entity 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:

TriggerScope
A tenant configuration changeThe tenant’s whole scope: engines, pools, schemas, registries
DELETE /data/cacheThe calling tenant, taken from the CEPT 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:prod:erp:main" }
RouteSuccessErrors
DELETE /data/cache200 {"cleared":true,"tenant":"<cept>"}403 no_tenant, 500 cache_clear_failed
DELETE /data/superadmin/tenant/:tenant/cache200 {"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.

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 CEPT 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.
  • When no cache is configured, every request is a miss and the script runs: caching is an optimisation, never a correctness dependency.
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.

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]
typeEntries liveCoherence across instances
localIn 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.
sharedIn one Redis-protocol server every instance points at: Dragonfly, Redis or Valkey.At once. One store, one set of generations.
tieredIn 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.
KeyTypeMeaning
namestringRequired. What an entity’s backend: names.
typelocal | shared | tieredRequired. The type names the role, not the product: dragonfly, redis and valkey are accepted spellings of shared, and local-inmemory of local.
ttldurationDefault entry lifetime. 5m when absent.
maxentriesintBound of the local store (local, tiered). Unbounded when absent; refused on shared.
coherencedurationtiered only: how long a local copy of a generation is trusted. Refused on the other types.
poolsizeintConnections to the server.
prefixstringKey namespace in the server. seshat when absent.
connectionstringA 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.

KeyTypeDefaultMeaning
backendstringthe only declared cacheA declared cache name. Required when more than one is declared; a name that is not declared fails the load.
ttldurationthe backend’sEntry lifetime for this entity.
scopetenant | usertenanttenant shares entries among the tenant’s callers; user keys them by caller as well.
invalidateonlistevery writeWhich operations orphan the entries: create, update, delete, purge, restore.
invalidaterelatedlist—Entities whose entries a write to this one orphans too. Each must be cached itself.
enabledbooltruefalse 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.

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.

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.

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.

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.

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.

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.

Readlocaltieredshared
Point read by primary key1.8×1.7×1.2×
Filtered list of five rows2.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.

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.

SurfaceBehaviour
Entity reads over REST and GraphQLCached only for entities that declare cache:; see above
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

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.