Skip to content

Lifetime from the schema

GraphQL only A plain HTTP response takes its lifetime from the origin header only.

@cacheControl lets you state cache lifetimes in your GraphQL schema, the same way Apollo Server’s cache-control plugin does. Annotate a type or a field with a maxAge, and GraphPilot derives a lifetime for every response that selects it, without any change to your server code.

It is for origins that do not compute a Cache-Control header themselves. An origin header that states a lifetime always wins over the schema; see precedence. The same annotation also carries a scope, which decides who shares the entry; that half is on scopes in the schema.

schema.graphql
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
inheritMaxAge: Boolean
swr: Int
) on OBJECT | INTERFACE | UNION | FIELD_DEFINITION
enum CacheControlScope {
PUBLIC
PRIVATE
}

Declare the directive in your SDL like this. Every argument, the default maxAge, what the deployment build reports and how to rename the directive are on the directive reference. None of the build’s findings fails a deployment; they are recorded as diagnostics.

A hint can be placed on a type (OBJECT, INTERFACE, UNION) or on a field (FIELD_DEFINITION):

  • Type-level hint: applies to every field in the schema that returns this type, unless that field has its own hint.
  • Field-level hint: applies to that field only, and always takes precedence over a type-level hint on its return type for the arguments it sets; the rest come from the type, see below.
schema.graphql
type Book @cacheControl(maxAge: 60) {
title: String
}
type Query {
# Inherits Book's type-level hint: maxAge 60
book: Book
# Field-level hint overrides Book's type-level hint for this field only
freshBook: Book @cacheControl(maxAge: 0)
}

A field hint overrides only the arguments it sets

Section titled “A field hint overrides only the arguments it sets”

When a field carries its own @cacheControl and its return type has one too, the two are merged argument by argument, as in Apollo Server. Each argument the field hint sets wins; each one it leaves out (maxAge, scope, swr, inheritMaxAge) is taken from the type’s hint. The return type is the named type under any list and non-null wrappers.

schema.graphql
type User @cacheControl(maxAge: 300, scope: PRIVATE) {
email: String
}
type Query {
# maxAge 60 from the field, scope PRIVATE from the type
viewer: User @cacheControl(maxAge: 60)
}

inheritMaxAge follows Apollo Server too. On the field it takes effect only when the type states no maxAge; a type that does state one wins. On the type it carries over to a field that states no maxAge of its own. A missing scope counts as PUBLIC only when neither hint states one.

The merge is done when your schema is built into a deployment, so it applies to a service from its next deployment on.

For every field in the query, GraphPilot resolves an effective maxAge using Apollo’s own rules, checked in this order:

  1. An explicit maxAge on this field’s hint always wins.
  2. Else, if inheritMaxAge: true is set and this is not a root field: take the parent field’s already-resolved effective maxAge.
  3. Else, if this is a root field or a field returning a composite type (an object, interface, or union): fall back to the default maxAge (0, not cacheable, unless you set one, see Default maxAge).
  4. Else (a non-root scalar leaf field with no hint): inherit the parent field’s effective maxAge.

The response’s overall policy is then the minimum maxAge across every field selected by the query, so the response can only be as fresh as its most restrictive field. Scopes accumulate instead; see how scopes compose.

schema.graphql
type Query {
book: Book
cachedBook: Book @cacheControl(maxAge: 60)
reader: Reader @cacheControl(maxAge: 40)
}
type Book {
title: String
cachedTitle: String @cacheControl(maxAge: 30)
}
type Reader {
book: Book @cacheControl(inheritMaxAge: true)
}
QueryEffective maxAgeWhy
{ book { cachedTitle } }0book is an unannotated root field returning a composite type: rule 3 gives it 0. That 0 is the minimum across the whole response regardless of cachedTitle’s own hint.
{ cachedBook { title } }60cachedBook has an explicit hint (rule 1). title is a non-root scalar with no hint, so it inherits cachedBook’s effective value (rule 4). Minimum of the two is 60.
{ cachedBook { cachedTitle } }30cachedBook resolves to 60 (rule 1). cachedTitle has its own explicit hint of 30 (rule 1), which is stricter. The minimum wins: 30.
{ reader { book { title } } }40reader resolves to 40 (rule 1). book sets inheritMaxAge: true with no explicit maxAge, so it takes reader’s effective value, 40 (rule 2). title inherits book’s value (rule 4). All three agree: 40.

The GraphPilot demo API runs this schema behind GraphPilot, so each row below can be checked against gp-cache-max-age on a HIT:

schema.graphql (excerpt)
type Product @cacheControl(maxAge: 3600, scope: PUBLIC, swr: 3600) {
id: ID!
name: String!
price: Int!
inventory: Inventory! @cacheControl(maxAge: 5, scope: PUBLIC)
}
type Category @cacheControl(maxAge: 86400, scope: PUBLIC, swr: 604800) {
id: ID!
name: String!
products: [Product!]! @cacheControl(inheritMaxAge: true, scope: PUBLIC)
}
type Activity @cacheControl(maxAge: 5, scope: PUBLIC, swr: 60) {
observedAt: String!
entries: [ActivityEntry!]! @cacheControl(inheritMaxAge: true, scope: PUBLIC)
}
type ActivityEntry {
productId: ID!
at: String!
}
type Query {
product(id: ID!): Product
categories: [Category!]! @cacheControl(maxAge: 86400, scope: PUBLIC)
activity(first: Int = 5): Activity! @cacheControl(maxAge: 5, scope: PUBLIC, swr: 60)
}
Querygp-cache-max-ageWhy
{ product(id: "p01") { id name price } }3600product states no hint and takes Product’s, and the scalars inherit it (rule 4).
{ product(id: "p01") { id name price inventory { available } } }5inventory states its own 5 (rule 1), and the response takes the minimum.
{ categories { id name products { id name } } }3600products asks to inherit the category’s day, but Product states its own hour, and a type that states a maxAge wins over inheritMaxAge.
{ activity { observedAt entries { productId at } } }5ActivityEntry states nothing, so inheritMaxAge takes the feed’s 5. Without it, entries would fall back to the default of 0 (rule 3) and nothing would be cached.

The second row also shows how the stale window resolves: inventory states no swr, so the response keeps Product’s hour. Five seconds after it was stored, the entry is served STALE while a refresh runs, and a reader can see a stock level that is older than five seconds. If stock must never be served stale, state swr: 0 on the field: a stated 0 wins the minimum.

For a field returning a union or an interface, the concrete type is known only once your origin has answered, and the policy is resolved from the query alone. The two arguments are therefore treated differently:

  • scope: every member counts. The scope of every possible type (each member of a union, each object type implementing an interface) joins the field’s, as a second field in the same selection would; see how scopes compose. One PRIVATE member makes the field private, whichever member the query selects and whatever the field or the union states. A scope: PUBLIC on the field or on the union does not override it, and there is no opt-out. A named scope on a member adds the names it partitions on.
  • maxAge: the members are not read. The maxAge comes from the field itself or from the union or interface type. When a member states a lower maxAge than the field resolves to, the deployment build reports a warning naming the field and those members; the response is still cached for the field’s value.
schema.graphql
union SearchResult = Book | Account
type Book @cacheControl(maxAge: 300) {
title: String
}
type Account @cacheControl(maxAge: 30, scope: PRIVATE) {
email: String
}
type Query {
search: SearchResult @cacheControl(maxAge: 60, scope: PUBLIC)
}

Here { search { ... on Book { title } } } resolves a maxAge of 60, from the field, and is private, because Account is: the query names only Book, but the origin decides what search returns. The build warns that Account states 30, below the 60 of Query.search. To cache search for at most 30 seconds, state that on the field or on the union.

A member’s scope is part of a schema policy, not one of its own: a query that selects no annotated field, as with an unannotated search above and nothing else, resolves no schema policy at all, as before. Without an origin header the response is not cached (CACHE_SKIPPED_NO_CACHE_POLICY). If the query also selects an annotated field elsewhere, an unannotated search counts as the default maxAge by rule 3, and its members’ scopes still join the response’s.

The member scopes are read when your schema is built into a deployment, so the change applies to a service from its next deployment on.

The portal’s Cache rules and the schemaAnalysis of a deployment show this effective scope: a field returning a union or an interface, and the union or interface’s own hint, read private when any member is private, or the named scope its members add, rather than the scope written on the field.

A hint on a member’s own field is still read through its type condition: in { search { ... on Book { publisher { name } } } }, a @cacheControl on Book.publisher, or on the type publisher returns, applies as usual.

A field the deployed schema does not declare

Section titled “A field the deployed schema does not declare”

The policy is read from the schema you deployed to GraphPilot, not from the one your origin runs. A query that selects a field that schema does not declare, at any depth, or a fragment on a type it does not know, has no hint to resolve for that selection and no scope to partition it by. Such a response is not stored (CACHE_SKIPPED_UNRESOLVED_SELECTION), whatever the rest of the query resolves to and whatever your origin’s Cache-Control says, and its policy drops to a maxAge of 0 with no stale window. The usual cause is an origin deployed ahead of its schema: deploy the schema to GraphPilot first, as described under deployments.

A resolved maxAge of zero, which is also the default when your directive declares none, means the response is not cached: CACHE_SKIPPED_NO_MAX_AGE. A mutation or subscription is never cached, whatever its hints; see when a response is cached.

When the schema decided, your logs carry the CACHE_POLICY_SCHEMA log line with:

  • max_age: the resolved lifetime,
  • max_age_set_by: the fields that set it (only those that declared the value rather than inherited it),
  • whether the result is private,
  • scope_defaulted: true when a selected hint stated no scope and was read as PUBLIC. It is the first thing to check when one caller reports another’s data.

The directive name and its argument names are configurable, and directives = [] under [cache_control] switches schema hints off entirely. See renaming directives.